THINK LIKE A PROGRAMMER

Typische Programmieraufgaben kreativ lösen am Beispiel von C++

V. Anton Spraul

Übersetzung aus dem Englischen von Knut Lorenzen

Impressum

Bibliografische Information der Deutschen Nationalbibliothek

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

ISBN 978-3-8266-9604-6

1. Auflage 2013

www.mitp.de

E-Mail: kundenservice@hjr-verlag.de

Telefon: +49 6221 / 489 -555

Telefax: +49 6221 / 489 -410

Übersetzung der englischen Originalausgabe: Copyright © 2012 by V. Anton Spraul. Title of English-language original: Think Like a Programmer, ISBN 978-1-59327-424-5, published by No Starch Press. German-language edition copyright © 2013 by Verlagsgruppe Hüthig Jehle Rehm GmbH. All rights reserved.

© 2013 mitp, eine Marke der Verlagsgruppe Hüthig Jehle Rehm GmbH Heidelberg, München, Landsberg, Frechen, Hamburg

Dieses Werk, einschließlich aller seiner Teile, ist urheberrechtlich geschützt. Jede Verwertung außerhalb der engen Grenzen des Urheberrechtsgesetzes ist ohne Zustimmung des Verlages unzulässig und strafbar. Dies gilt insbesondere für Vervielfältigungen, Übersetzungen, Mikroverfilmungen und die Einspeicherung und Verarbeitung in elektronischen Systemen.

Die Wiedergabe von Gebrauchsnamen, Handelsnamen, Warenbezeichnungen usw. in diesem Werk berechtigt auch ohne besondere Kennzeichnung nicht zu der Annahme, dass solche Namen im Sinne der Warenzeichen- und Markenschutz-Gesetzgebung als frei zu betrachten wären und daher von jedermann benutzt werden dürften.

Lektorat: Sabine Schulz

Sprachkorrektorat: Petra Heubach-Erdmann

Covergestaltung: Charlie Wylie

electronic publication: III-satz, Husby, www.drei-satz.de

Dieses Ebook verwendet das ePub-Format und ist optimiert für die Nutzung mit dem iBooks-reader auf dem iPad von Apple. Bei der Verwendung anderer Reader kann es zu Darstellungsproblemen kommen.

Der Verlag räumt Ihnen mit dem Kauf des ebooks das Recht ein, die Inhalte im Rahmen des geltenden Urheberrechts zu nutzen. Dieses Werk, einschließlich aller seiner Teile, ist urheberrechtlich geschützt. Jede Verwertung außerhalb der engen Grenzen des Urheherrechtsgesetzes ist ohne Zustimmung des Verlages unzulässig und strafbar. Dies gilt insbesondere für Vervielfältigungen, Übersetzungen, Mikroverfilmungen und Einspeicherung und Verarbeitung in elektronischen Systemen.

Der Verlag schützt seine ebooks vor Missbrauch des Urheberrechts durch ein digitales Rechtemanagement. Bei Kauf im Webshop des Verlages werden die ebooks mit einem nicht sichtbaren digitalen Wasserzeichen individuell pro Nutzer signiert.

Bei Kauf in anderen ebook-Webshops erfolgt die Signatur durch die Shopbetreiber. Angaben zu diesem DRM finden Sie auf den Seiten der jeweiligen Anbieter.

Danksagungen

Kein Buch ist wirklich das Werk nur eines einzelnen Autors, und bei Think Like a Programmer ist mir eine Menge Hilfe zuteilgeworden.

Ich bin der gesamten Belegschaft von No Starch Press dankbar, insbesondere Keith Fancher und Alison Law, die das Buch während der Entstehungsphase redigierten, prägten und umsorgten. Außerdem bin ich Bill Pollock für seine Entscheidung, mich überhaupt anzuheuern, zu Dank verpflichtet – ich hoffe, er ist mit dem Ergebnis genauso zufrieden wie ich. Die Mitarbeiter von No Starch Press waren beim Schriftverkehr mit mir unermüdlich freundlich und hilfsbereit. Ich freue mich darauf, sie eines Tages persönlich kennenzulernen, um zu überprüfen, wie sehr sie den Comicfiguren auf der Webseite des Verlags ähneln.

Dan Randall hat als technischer Redakteur großartige Arbeit geleistet. Seine zahlreichen Anregungen jenseits der fachlichen Prüfung haben mir dabei geholfen, das Manuskript in vielen Bereichen zu verbessern.

Die wichtigsten Menschen in meinem Leben, Mary Beth und Madeline, gaben mir Zuneigung, Unterstützung und sorgten für Enthusiasmus – und, ganz entscheidend, ließen mir die Zeit zum Schreiben.

Und schließlich an alle Studierenden, die in all den Jahren das Programmieren erlernt haben: Danke, dass ich euch unterrichten durfte. Die in diesem Buch erläuterten Vorgehensweisen und Strategien sind das Resultat unserer gemeinsamen Anstrengungen. Ich kann mir nur wünschen, dass wir künftigen Programmierern das Leben etwas leichter gemacht haben.

Einführung

Bereitet es Ihnen Mühe, Programme zu schreiben, obwohl Sie davon überzeugt sind, Programmiersprachen eigentlich verstanden zu haben? Lesen Sie manchmal ein ganzes Kapitel in einem Buch über Programmierung und nicken dabei ständig wissend, aber es fällt Ihnen dennoch schwer, das Gelesene in Ihren eigenen Programmen umzusetzen? Können Sie im Internet gefundene Beispielprogramme so gut verstehen, dass Sie anderen die Bedeutung jeder einzelnen Codezeile erklären können, aber glauben, einen Krampf im Kopf zu haben, wenn Sie einer Programmieraufgabe gegenüberstehen und den leeren Bildschirm Ihres Texteditors sehen?

So geht es nicht nur Ihnen. Ich unterrichte seit mehr als 15 Jahren Programmierung, und den meisten meiner Studenten ist es irgendwann während ihrer Ausbildung ebenfalls so ergangen. Bei der hier fehlenden Fähigkeit handelt es sich darum, Problemlösungen zu finden, also anhand der Beschreibung einer bestimmten Aufgabe ein neues Programm zu schreiben, das diese Problemstellung löst. Nicht jede Art der Programmierung erfordert umfangreiches Lösen von Problemen. Falls Sie ein bereits bestehendes Programm nur geringfügig ändern, debuggen oder um Testcode ergänzen, geschieht die Programmierung möglicherweise so „mechanisch”, dass Ihr Erfindungsreichtum gar nicht erst auf die Probe gestellt wird. Früher oder später erfordern jedoch alle Programme das Lösen von Problemstellungen, und alle guten Programmierer können das.

Problemlösungen zu finden ist schwierig. Bei manchen Menschen, den „Naturtalenten” (in der Welt der Programmierung das Pendant zu begabten Athleten im Sport), scheint es so einfach zu sein. Diese wenigen Auserwählten übersetzen komplizierte Ideen wie im Schlaf in Quellcode. Wenn man ein Java-Gleichnis bemühen möchte, könnte man sagen, dass der Java-Code kompiliert im Gehirn abläuft, während wir anderen eine virtuelle Maschine verwenden müssen, die den Code nur interpretiert.

Als Programmierer kein Naturtalent zu sein, ist aber auch nicht weiter schlimm. Wäre dem so, gäbe es nur sehr wenige Programmierer auf der Welt. Dessen ungeachtet sind mir schon zu viele achtbare angehende Programmierer begegnet, die sich zu lange frustriert abmühen. Schlimmstenfalls geben sie das Programmieren komplett auf und sind davon überzeugt, ihr Ziel nie zu erreichen und dass nur Leute mit einer angeborenen Gabe gute Programmierer sind.

Warum aber ist es so schwierig, zu erlernen, Programmieraufgaben zu lösen?

Zum Teil liegt es daran, dass das Lösen von Problemen und das Erlernen der Syntax einer Programmiersprache ganz unterschiedliche Tätigkeiten sind und daher völlig andere „Muskeln” des Verstands beanspruchen. Beim Erlernen der Syntax einer Programmiersprache, dem Lesen von Quellcode und dem Auswendiglernen einer Programmierschnittstelle (API, englisch Application Programming Interface) handelt es sich um vornehmlich analytische Vorgänge in der linken Hirnhälfte. Ein originelles Programm unter Verwendung der vorher erlernten Werkzeuge und Fähigkeiten zu schreiben, ist hingegen eine Aktivität der rechten, kreativen Hirnhälfte.

Nehmen wir an, Sie möchten einen Zweig entfernen, der in die Regenrinne Ihres Hauses gelangt ist, aber Ihre Leiter ist zu kurz, um den Zweig zu erreichen. Sie begeben sich in Ihre Garage und suchen nach irgendeinem Objekt (oder einer Kombination von Objekten), das es Ihnen erlaubt, den Zweig aus der Regenrinne zu entfernen. Gibt es eine Möglichkeit, die Leiter zu verlängern? Gibt es ein Objekt, das Sie auf der Leiter stehend dazu verwenden könnten, den Zweig zu erreichen und fortzuschaffen? Vielleicht könnten Sie auch vom Inneren des Hauses aus einfach aufs Dach steigen und den Zweig von ober her zu fassen bekommen. Das ist mit Problemlösen gemeint, und es handelt sich dabei um eine kreative Tätigkeit. Ob Sie es glauben oder nicht, wenn Sie ein Programm erstmals entwerfen, sind Ihre Denkvorgänge und diejenigen der Person, die den Zweig entfernen möchte, ziemlich ähnlich und unterscheiden sich deutlich von den Denkvorgängen einer Person, die eine bereits vorhandene for-Schleife debuggt.

Allerdings legen die meisten Bücher über Programmierung ihren Schwerpunkt auf Syntax und Semantik. Nun sind Syntax und Semantik einer Programmiersprache natürlich unverzichtbar, aber sie sind nur der erste Schritt beim Lernen, wie man in der Sprache programmiert. Die meisten Bücher für Programmieranfänger führen im Wesentlichen vor, wie man ein Programm versteht, nicht, wie man eines schreibt. Diejenigen Bücher, die sich auf das Schreiben von Programmen konzentrieren, sind häufig tatsächlich „Kochbücher”, in denen „Rezepte” für bestimmte Gegebenheiten aufgeführt sind. Derartige Bücher können zwecks Zeitersparnis durchaus wertvoll sein, sind aber ungeeignet, um zu lernen, eigenständig Code zu schreiben. Denken Sie an Kochbücher im eigentlichen Sinn. Auch wenn hervorragende Köche Kochbücher besitzen, wird niemand, der sich nur auf Kochbücher verlässt, zu einem großartigen Koch. Ein exzellenter Koch kennt sich mit Zutaten, deren Vorbereitung und Garmethoden aus und weiß, wie er diese miteinander kombinieren muss, um fantastische Gerichte zuzubereiten. Um eine leckere Mahlzeit anzurichten, benötigt ein wirklich guter Koch nur eine vollständig ausgestattete Küche. In vergleichbarer Weise kennt sich ein ausgezeichneter Programmierer mit der Syntax der Sprache, Anwendungsframeworks, Algorithmen und den Prinzipien der Softwareentwicklung aus und versteht es, diese miteinander zu verbinden, um tolle Programme zu erstellen. Geben Sie einem exzellenten Programmierer eine Liste mit Vorgaben und lassen Sie ihn dann auf eine vollständig ausgerüstete Programmierumgebung los; es werden großartige Dinge geschehen!

Bei der Ausbildung von Programmierern gibt es gegenwärtig im Bereich des Problemlösens kaum Orientierungshilfen. Stattdessen geht man davon aus, dass Programmierer, die Zugriff auf alle erforderlichen Programmierwerkzeuge besitzen, irgendwann schon lernen werden, gute Programme zu schreiben, wenn sie dazu aufgefordert werden. Das lässt sich auch nicht völlig von der Hand weisen, aber von jetzt bis „irgendwann” kann ein ziemlich langer Zeitraum sein. Vom Programmieranfänger bis zum abgeklärten Programmierer ist es ein langer und steiniger Weg, und zu viele der Leute, die sich auf den Weg gemacht haben, erreichen ihr Ziel nie.

Anstatt durch Versuch und Irrtum zu lernen, können Sie sich das Lösen von Problemen auch systematisch aneignen. Darum geht es in diesem Buch. Sie können Techniken zum Ordnen Ihrer Gedanken und Verfahren zum Auffinden von Lösungen und Strategien für bestimmte Arten von Problemen erlernen. Lassen Sie Ihrer Kreativität beim Studieren der verschiedenen Ansätze freien Lauf. Geben Sie sich hier keiner Täuschung hin: Programmieren und insbesondere das Lösen von Problemen sind kreative Tätigkeiten. Kreativität bleibt rätselhaft und niemand vermag genau zu sagen, wie ein kreativer Kopf funktioniert. Wenn wir aber imstande sind, das Komponieren von Musik zu erlernen und Ratschläge zum kreativen Schreiben zu befolgen, oder uns zeigen lassen können, wie man malt, dann können wir auch lernen, Aufgabenstellungen beim Programmieren kreativ zu lösen. Dieses Buch möchte Ihnen nicht vorschreiben, was genau Sie tun sollen. Es soll Ihnen vielmehr dabei helfen, Ihre verborgenen Problemlösungsfähigkeiten weiterzuentwickeln, sodass Sie dann schon wissen werden, was zu tun ist. Es geht hier vor allem darum, Ihnen dabei zu helfen, ein Programmierer zu werden, wie er im Buche steht.

Mein Ziel ist es, dass Sie (und alle anderen Leser des Buchs) es lernen, beliebige Programmieraufgaben systematisch in Angriff zu nehmen und dabei zuversichtlich sind, die jeweilige Aufgabe schließlich lösen zu können. Ich wünsche mir, dass Sie nach der Lektüre des Buchs wie ein Programmierer denken und dass Sie sich für einen Programmierer halten.

Über dieses Buch

Nachdem die Notwendigkeit dieses Buches erläutert wurde, sind noch einige Bemerkungen dazu nötig, um was es sich beim vorliegenden Buch eigentlich handelt – und um was nicht.

Voraussetzungen

In diesem Buch wird davon ausgegangen, dass Ihnen die grundlegende Syntax und die Semantik der Programmiersprache C++ geläufig sind und Sie bereits damit begonnen haben, Programme zu schreiben. In den meisten Kapiteln wird von Ihnen erwartet, dass Sie bestimmte C++-Grundlagen kennen. In diesen Kapiteln finden Sie eingangs einen Überblick über diese Grundlagen. Machen Sie sich keine Sorgen, falls Sie noch im Begriff sind, die Sprache zu erlernen. Es gibt eine Vielzahl ausgezeichneter Bücher über C++ und Sie können das Lösen von Problemen und die Syntax gleichzeitig erlernen. Sie müssen jedoch die jeweils relevante Syntax erlernt haben, bevor Sie versuchen, die Aufgabenstellungen eines Kapitels in Angriff zu nehmen.

Ausgewählte Themen

Die im Buch behandelten Themen decken die Bereiche ab, bei denen ich Programmieranfänger am häufigsten mit Schwierigkeiten habe kämpfen sehen. Diese Bereiche stellen gleichzeitig einen umfassenden Querschnitt der für Anfänger und fortgeschrittene Anfänger maßgeblichen Themengebiete dar.

Ich muss jedoch betonen, dass es sich hier nicht um ein „Kochbuch” mit Algorithmen oder Verfahren zum Lösen bestimmter Problemstellungen handelt. Auch wenn in späteren Kapiteln erläutert wird, wie sich wohlbekannte Algorithmen oder Verfahren einsetzen lassen, sollten Sie dieses Buch nicht als „Spickzettel” verwenden, um bestimmte Aufgabenstellungen zu überwinden, oder sich ausschließlich auf die Kapitel konzentrieren, die in direktem Zusammenhang mit Ihren aktuellen Bemühungen stehen. Sie sollten vielmehr das gesamte Buch durcharbeiten und nur dann Lehrstoff überspringen, wenn Ihnen die erforderlichen Voraussetzungen fehlen, um der Themenbehandlung zu folgen.

Programmierstil

An dieser Stelle eine kurze Anmerkung zum im Buch verwendeten Programmierstil. Dieses Buch befasst sich weder mit Hochleistungs-Programmierung noch mit der Erstellung möglichst kompakter, effizienter Codes. Der in den Codebeispielen von mir verwendete Programmierstil soll vor allem gut lesbar und möglichst verständlich sein. In einigen Fällen werden mehrere Schritte ausgeführt, um ein Ziel zu erreichen, auch wenn dies in einem einzigen Schritt möglich wäre, damit das Prinzip deutlich wird, das ich demonstrieren möchte.

Es werden dennoch auch einige Aspekte des Programmierstils im Buch behandelt – allerdings nur bedeutsame, wie etwa, was eine Klasse beinhalten sollte und was nicht, nicht aber belanglose, wie beispielsweise wie der Code einzurücken ist. Als angehender Programmierer möchten Sie selbstverständlich einen einheitlichen, gut lesbaren Stil in allen Ihren Werken verwenden.

Übungen

Das Buch enthält eine Reihe von Programmierübungen, ist jedoch kein Schulbuch und Sie werden am Ende des Buches keine Antworten auf die Übungsaufgaben finden. Die Übungen bieten Ihnen die Möglichkeit, die in den einzelnen Kapiteln vorgestellten Konzepte anzuwenden. Ob Sie davon Gebrauch machen, steht Ihnen natürlich frei, es ist aber ganz entscheidend, dass Sie diese Konzepte in die Tat umsetzen. Mit dem einfachen Durchlesen des Buchs wird nichts erreicht. Wie bereits erwähnt, möchte Ihnen dieses Buch nicht vorschreiben, was genau Sie in einer bestimmten Situation tun sollen. Durch Anwendung der in diesem Buch vorgestellten Techniken werden Sie selbst die Fähigkeit entwickeln, herauszufinden, was zu tun ist. Ein weiteres Hauptziel dieses Buches, die Steigerung Ihres Vertrauens in die eigenen Fähigkeiten, erfordert darüber hinaus Erfolgserlebnisse. Tatsächlich handelt es sich hier um eine bestens geeignete Methode, um festzustellen, ob Sie bereits genug Übungen eines bestimmten Themenbereichs durchgearbeitet haben: wenn Sie sich sicher sind, andere Aufgabenstellungen aus diesem Themenbereich lösen zu können. Und zu guter Letzt sollten die Übungsaufgaben auch Spaß machen. Auch wenn es sicherlich Momente geben wird, in denen Sie sich lieber mit etwas anderem beschäftigen möchten, sollte die Ausarbeitung der Lösung einer Problemstellung bei der Programmierung eine lohnenswerte Herausforderung sein.

Stellen Sie sich dieses Buch als eine Art Hindernislauf für Ihren Verstand vor. Hindernisläufe sorgen für Kraft, Ausdauer und Geschicklichkeit sowie für Zutrauen beim Ausbilder. Durch die Lektüre der einzelnen Kapitel und die praktische Umsetzung möglichst vieler Übungen werden Sie Selbstvertrauen aufbauen und Problemlösungsfähigkeiten entwickeln, die bei allen möglichen Aufgabenstellungen Anwendung finden. Falls Sie künftig auf eine schwierige Problemstellung stoßen, werden Sie wissen, wie die Aufgabe in Angriff zu nehmen ist.

Warum C++?

Die Programmbeispiele dieses Buches sind in C++ programmiert. Davon einmal abgesehen geht es in diesem Buch um Problemlösungen durch Programme – nicht speziell um C++. Sie werden hier also kaum Tipps und Tricks zu C++ finden, denn die im Buch vorgestellten allgemeinen Ideen sind in jeder Programmiersprache einsetzbar. Gleichwohl kann man nicht über Programmierung reden, ohne auch Programme zu erwähnen, und daher musste eine konkrete Programmiersprache ausgewählt werden.

Es gibt mehrere Gründe für die Auswahl von C++. Erstens ist C++ in vielen verschiedenen Bereichen weit verbreitet. Zweitens stammt C++ von der streng prozeduralen Sprache C ab, sodass sich C++-Programme sowohl prozedural als auch objektorientiert schreiben lassen. Die objektorientierte Programmierung ist inzwischen so verbreitet, dass sie bei der Erörterung von Problemlösungen nicht weggelassen werden darf, aber viele der fundamentalen Konzepte bei der Lösung von Problemen lassen sich bestens durch Begriffe der streng prozeduralen Programmierung beschreiben, was sowohl den Code als auch die Erläuterungen vereinfacht. Drittens erlaubt es C++ als maschinennahe (low-level) Sprache mit komplexen (high-level) Bibliotheken, beide Ebenen der Programmierung zu untersuchen. Die besten Programmierer können bei Bedarf maschinennahe Lösungen implementieren und gleichzeitig die komplexen Bibliotheken und APIs nutzen, um die zur Programmierung erforderliche Zeit zu verringern. Und schließlich – teilweise auch als Resultat der anderen genannten Gründe – ist C++ eine ausgezeichnete Wahl, weil Sie Problemstellungen in allen anderen Programmiersprachen lösen können, wenn Sie gelernt haben, diese in C++ zu lösen. Viele Programmierer haben festgestellt, dass sich die in einer bestimmten Programmiersprache erworbenen Kenntnisse auch in anderen Sprachen als nützlich erweisen. Für C++ trifft dies aufgrund des zweigleisigen Ansatzes (prozedural und objektorientiert) in besonderem Maße zu. Dazu trägt – offen gestanden – auch der Schwierigkeitsgrad beim Erlernen von C++ bei. C++ ist nun mal der „wahre Jakob” – Programmieren ohne Netz und doppelten Boden. Anfangs ist dies zwar abschreckend, aber sobald Sie die ersten Erfolge in C++ erzielen, wird Ihnen klar werden, dass Sie nicht nur ein wenig Code zusammenstückeln können, sondern zu einem Programmierer werden.

Kapitel 1: Strategien zur Problemlösung

In diesem Buch geht es um das Lösen von Problemstellungen, aber was genau bedeutet das eigentlich? Wenn der Begriff in einer normalen Unterhaltung gebraucht wird, ist damit oft etwas ganz anderes gemeint als das, was wir hier meinen. Wenn Ihr 15 Jahre altes Auto blaue Rauchwölkchen aus dem Auspuff ausstößt, im Leerlauf ruckelt und sich der Benzinverbrauch drastisch erhöht hat, handelt es sich um eine Problemstellung, die sich mittels fahrzeugtechnischer Kenntnisse, einer Untersuchung des Automobils, Ersatzteilen und gebräuchlicher Werkzeuge lösen lässt. Wenn Sie nun Ihren Bekannten von diesem Problem berichten, könnte einer davon sagen: „Ach, du solltest das Auto gegen ein neues austauschen. Schon ist das Problem gelöst.” Allerdings wäre der Vorschlag Ihres Bekannten keine echte Lösung des Problems, sondern ein Weg, das Problem zu umgehen.

Problemstellungen beinhalten gewisse Randbedingungen, unantastbare Vorschriften hinsichtlich der Aufgabenstellung oder Bestimmungen, auf welche Art und Weise das Problem zu lösen ist. Im Fall des liegengebliebenen Fahrzeugs wäre eine dieser Bedingungen, dass Sie das alte Gefährt reparieren wollen und eben nicht ein neues Auto kaufen möchten. Weitere Randbedingungen wären beispielsweise die Gesamtkosten der Reparatur, deren Dauer oder die Auflage, dass nur für diese Instandsetzung kein neues Werkzeug beschafft werden soll.

Wenn eine Problemstellung mittels eines Programms gelöst wird, gibt es ebenfalls Randbedingungen. Dazu gehören üblicherweise die Programmiersprache, die Plattform (läuft das Programm auf einem PC, einem iPhone oder anderswo?), das Leistungsverhalten (manche Spiele müssen mindestens 30 mal pro Sekunde die Anzeige aktualisieren; einer geschäftlichen Anwendung steht möglicherweise nur eine begrenzte Zeitspanne zur Verfügung, um auf Benutzereingaben zu reagieren) oder der Speicherbedarf. Gelegentlich betreffen die Randbedingungen auch anderen Code, auf den zugegriffen werden soll. Vielleicht darf ein bestimmtes Programm keinen quelloffenen Code verwenden. Oder genau das Gegenteil: Möglicherweise darf ausschließlich quelloffener Code verwendet werden.

Für Programmierer können wir somit das Lösen einer Problemstellung wie folgt definieren: Schreiben eines eigenen Programms, das eine festgelegte Menge an Aufgaben erledigt und dabei allen genannten Randbedingungen genügt.

Programmieranfänger sind oft so darauf erpicht, den ersten Teil dieser Definition (ein Programm zu schreiben, um eine gewisse Aufgabe zu erledigen) in die Tat umzusetzen, dass sie dabei den zweiten Teil übersehen, nämlich den genannten Randbedingungen gerecht zu werden. Ich bezeichne ein solches Programm, das korrekte Ergebnisse zu liefern scheint, dabei aber eine oder mehrere der Randbedingungen verletzt, als Kobayashi Maru. Falls Ihnen dieser Name nichts sagt, sind Sie offenbar mit einem der Prüfsteine der Geek-Kultur, nämlich dem Film Star Trek II: Der Zorn des Khan, nicht hinreichend vertraut. In dem Film gibt es eine Rahmenhandlung, in der es um eine Prüfung angehender Offiziere der Sternenflottenakademie geht. Die Kadetten befinden sich dabei auf der Brücke eines simulierten Raumschiffs und leiten dort als Captain eine Mission, die zu einer unlösbaren Zwangslage führt. Die unschuldigen Menschen an Bord des schwer beschädigten Raumschiffs Kobayashi Maru werden sterben. Um diese Menschen zu retten, müsste eine Schlacht mit den Klingonen geschlagen werden, die zur sicheren Zerstörung des eigenen Schiffs des Captains führen würde. Der Kobayashi Maru-Test soll das Verhalten des Kadetten in einer ausweglosen Situation offenbaren. Es gibt keine Möglichkeit, den Test zu bestehen; jede Entscheidung führt zu einem verlustreichen Resultat. Gegen Ende des Films erfahren wir dann, dass Captain Kirk die Simulation manipuliert hat, um den Test tatsächlich bestehen zu können. Kirk war zwar schlau, aber er hat das Dilemma der Kobayashi Maru nicht gelöst, sondern nur umgangen.

Die Problemstellungen, denen Sie als Programmierer begegnen werden, sind glücklicherweise lösbar, aber viele Programmierer greifen dennoch auf Captain Kirks Ansatz zurück. In manchen Fällen geschieht dies versehentlich. („Verflixt! Meine Lösung arbeitet nur mit weniger als 100 Datensätzen korrekt. Es muss aber mit beliebig vielen funktionieren. Das muss ich noch mal durchdenken.”) In anderen Fällen ist die Missachtung von Randbedingungen Absicht, etwa als List, um einen vom Vorgesetzten oder Ausbilder auferlegten Termin halten zu können. Und in wieder anderen Fällen weiß der Programmierer einfach nicht, wie er alle Bedingungen erfüllen kann. Das Schlimmste, was ich erlebt habe, war ein Student, der jemanden dafür bezahlte, ein Programm zu schreiben. Unabhängig von den Beweggründen ist ein Kobayashi Maru stets gewissenhaft zu vermeiden.

1.1 Klassische Rätsel

Im weiteren Verlauf des Buches wird Ihnen auffallen, dass sich die Einzelheiten des Quellcodes bei den verschiedenen Aufgabenstellungen zwar ändern, in den Lösungsansätzen jedoch gewisse Muster zum Vorschein kommen. Das ist deshalb von Bedeutung, weil es uns letztendlich erlaubt, beliebige Problemstellungen zuversichtlich anzugehen, und zwar unabhängig davon, ob wir mit dem Aufgabengebiet bereits umfangreiche Erfahrung haben oder nicht. Erfahrene Problemlöser sind in der Lage, schnell eine Analogie zu erkennen, eine Übereinstimmung zwischen einer bereits gelösten und einer ungelösten Aufgabe, die sich ausnutzen lässt. Wenn wir erkennen, dass eine Eigenschaft der Problemstellung A einer Eigenschaft der Problemstellung B entspricht und wir B bereits gelöst haben, haben wir wertvolle Erkenntnisse zur Lösung von A gewonnen.

In diesem Abschnitt werden wir einige klassische Rätsel betrachten, die zwar nicht der Welt der Programmierung entstammen, uns aber Einsichten bescheren, die wir auf Problemstellungen der Programmierung anwenden können.

1.1.1 Fuchs, Gans und Getreidesack

Bei der ersten klassischen Aufgabenstellung handelt es sich um ein Rätsel, bei dem ein Bauer einen Fluss überqueren muss. Vermutlich ist es Ihnen bereits in der einen oder anderen Variante (z. B. Wolf, Ziege, Kohlkopf) begegnet.

Aufgabe: Wie kann der Fluss überquert werden?

Ein Bauer möchte zusammen mit einem Fuchs, einer Gans und einem Getreidesack einen Fluss überqueren. Der Bauer besitzt ein Ruderboot, das jedoch nur Platz für den Bauern und jeweils eines der drei Objekte bietet. Leider sind sowohl Fuchs als auch Gans sehr hungrig. Der Fuchs darf also nicht zusammen mit der Gans zurückgelassen werden, da er sie dann fressen würde. Auch die Gans darf nicht zusammen mit dem Getreidesack alleingelassen werden, weil sie in diesem Fall das Getreide frisst. Wie stellt der Bauer es an, alles zur anderen Uferseite zu transportieren?

In Abbildung 1.1 sind die Vorgaben des Rätsels dargestellt. Falls es Ihnen noch unbekannt ist, sollten Sie nun innehalten und ein paar Minuten lang probieren, es zu lösen. Ist es Ihnen jedoch bekannt, versuchen Sie, sich an die Lösung zu erinnern und ob Sie das Rätsel selbst lösen konnten.

Abb. 1.1: Fuchs, Gans und Getreidesack. Das Boot kann jeweils nur eines der Objekte gleichzeitig transportieren. Weder Fuchs und Gans noch Gans und Getreidesack dürfen zusammen am selben Ufer zurückgelassen werden.

Nur wenige Leute sind in der Lage, dieses Rätsel zu lösen, zumindest nicht ohne einen Tipp. Ich konnte es jedenfalls nicht. Normalerweise werden folgende Überlegungen angestellt: Da der Bauer jeweils nur eines der Objekte transportieren kann, muss er den Fluss mehrmals überqueren, um alles zur anderen Uferseite zu befördern. Falls der Bauer auf der ersten Fahrt den Fuchs mitnimmt, wäre die Gans mit dem Getreidesack allein und würde das Getreide auffressen. Nähme der Bauer zunächst den Getreidesack mit, blieben Fuchs und Gans zurück, und der Fuchs fräße die Gans. Folglich muss der Bauer als Erstes die Gans mitnehmen (siehe Abbildung 1.2).

Abb. 1.2: Der erste Schritt zur Lösung des Fuchs-Gans-Getreidesack-Rätsels. Von hier aus scheinen jedoch alle weiteren Schritte zum Scheitern zu führen.

So weit, so gut. Aber bei der zweiten Fahrt muss der Bauer entweder den Fuchs oder den Getreidesack mitnehmen. Was er auch wählt, verbleibt bei der Gans auf der anderen Uferseite, wenn er zurückkehrt, um das letzte Objekt zu holen. Also bleiben entweder Fuchs und Gans oder aber Gans und Getreidesack zusammen zurück. Da beide Möglichkeiten nicht in Frage kommen, scheint die Aufgabe unlösbar.

Sollten Sie das Rätsel schon kennen, werden Sie sich wahrscheinlich an den entscheidenden Punkt der Lösung erinnern. Der Bauer nimmt bei der ersten Fahrt, wie oben erläutert, die Gans mit. Nehmen wir an, der Bauer transportiert bei der zweiten Fahrt den Fuchs. Statt nun aber den Fuchs bei der Gans zu belassen, befördert er die Gans wieder zurück. Der Bauer nimmt dann den Getreidesack mit, den er zusammen mit dem Fuchs auf der anderen Uferseite belässt, und kehrt dann zurück, um schließlich ein viertes Mal mit der Gans zum anderen Ufer zu rudern. Die vollständige Lösung ist in Abbildung 1.3 dargestellt.

Abb. 1.3: Schrittweise Lösung des Fuchs-Gans-Getreidesack-Rätsels

Das Rätsel ist so schwierig, weil die meisten Leute gar nicht auf den Gedanken kommen, eines der Objekte wieder zurückzubefördern. Manche Leute sind sogar der Ansicht, dass das Rätsel unfair sei, und beschweren sich: „Du hast mit keinem Wort erwähnt, dass ich die Objekte zurücktransportieren darf!” Das stimmt zwar, aber es ist ebenso richtig, dass nichts in der Aufgabenstellung darauf hinweist, dass ein Rücktransport verboten ist.

Denken Sie doch einmal darüber nach, wie viel einfacher das Rätsel zu lösen wäre, wenn die Möglichkeit eines Rücktransports ausdrücklich erwähnt wäre: Der Bauer besitzt ein Ruderboot, das zum Transport der Objekte in beide Richtungen verwendet werden kann, jedoch nur Platz für den Bauern und jeweils eines der drei Objekte bietet. Mit diesem offensichtlichen Hinweis würden viel mehr Leute die Lösung herausfinden. Hierdurch wird ein wichtiges Prinzip beim Problemlösen deutlich: Falls Sie sich nicht aller erlaubten Handlungsmöglichkeiten bewusst sind, können Sie das Problem möglicherweise nicht lösen. Wir bezeichnen diese Handlungsmöglichkeiten als Operationen. Durch die Aufzählung sämtlicher möglicher Operationen lassen sich viele Problemstellungen lösen, indem alle Kombinationen der Operationen ausprobiert werden, bis wir eine finden, die funktioniert. Oder allgemeiner ausgedrückt: Durch die Umformulierung einer Problemstellung in formaleren Begriffen lassen sich häufig Lösungen finden, die uns anderenfalls entgangen wären.

Lassen Sie uns für einen Augenblick vergessen, dass wir die Lösung bereits kennen, und versuchen wir, das vorliegende Rätsel formaler auszudrücken. Zunächst zählen wir die Randbedingungen auf. Die entscheidenden sind hier:

Der Bauer kann nur jeweils ein Objekt mit dem Boot transportieren.

Fuchs und Gans dürfen nicht zusammen am selben Ufer belassen werden.

Gans und Getreidesack dürfen nicht zusammen am selben Ufer belassen werden.

Diese Aufgabenstellung ist ein schönes Beispiel für die Bedeutung von Randbedingungen. Wenn irgendeine der Randbedingungen entfernt wird, ist das Rätsel simpel. Entfernen wir die erste Randbedingung, kann der Bauer einfach alle drei Objekte auf der ersten Fahrt mitnehmen. Selbst wenn er mit dem Boot nur zwei Objekte befördern dürfte, könnte er zunächst Fuchs und Getreidesack und nach der Rückkehr die Gans transportieren. Entfällt die zweite Randbedingung (während die anderen beiden weiterhin gelten), müssen wir nur ein wenig Vorsicht walten lassen. Es muss erst die Gans, dann der Fuchs und schließlich der Getreidesack ans andere Ufer verfrachtet werden. Wenn wir also eine der Randbedingungen übersehen oder ignorieren, gelangen wir letztendlich zu einem Kobayashi Maru.

Lassen Sie uns nun die Operationen aufzählen. Zur Formulierung der Operationen dieses Rätsels gibt es verschiedene Möglichkeiten. Wir könnten etwa eine ausführliche Liste der Handlungen erstellen, die wir glauben ausführen zu können:

Operation: Fuchs zur anderen Uferseite befördern.

Operation: Gans zur anderen Uferseite befördern.

Operation: Getreidesack zur anderen Uferseite befördern.

Denken Sie jedoch daran, dass es das Ziel der formalen Umformulierung ist, Erkenntnisse über einen Lösungsweg zu gewinnen. Wenn wir das Rätsel nicht schon gelöst und die „verborgene” Operation „Rücktransport der Gans” entdeckt hätten, würden wir diese beim Erstellen der Liste der Handlungsmöglichkeiten nicht aufspüren. Stattdessen sollten wir versuchen, die Operationen in allgemeiner Form oder parametrisiert darzustellen:

Operation: Boot von einem Ufer zum anderen rudern.

Operation: Falls das Boot leer ist, mit einem Objekt vom Ufer beladen.

Operation: Falls das Boot nicht leer ist, Objekt entladen und am Ufer belassen.

Wenn man das Rätsel in den allgemeinsten Begriffen beschreibt, ermöglicht die zweite Liste von Operationen es uns, die Aufgabenstellung ohne ein Aha-Erlebnis hinsichtlich des Rücktransports der Gans zu lösen. Wenn wir eine Liste aller möglichen Abfolgen von Handlungen erstellen und diese jeweils beenden, wenn eine der Randbedingungen verletzt wird oder ein schon früher erreichter Zustand erreicht wird, werden wir irgendwann auf die Abfolge in Abbildung 1.3 stoßen und das Rätsel lösen. Die dem Rätsel innewohnende Schwierigkeit wird durch die formale Neuformulierung der Randbedingungen und Operationen umgangen.

Gewonnene Erkenntnisse

Welche Einsichten liefert uns das Fuchs-Gans-Getreidesack-Rätsel?

Die Neuformulierung der Problemstellung in einer formaleren Art und Weise ist eine ausgezeichnete Vorgehensweise, um Einblick in das Problem zu gewinnen. Viele Programmierer suchen den Kontakt zu anderen Programmierern, um eine Aufgabenstellung zu diskutieren. Nicht nur, weil die anderen Programmierer vielleicht die Lösung kennen, sondern auch, weil die ausdrückliche Artikulierung der Problemstellung oft neue und nützliche Einfälle auslöst. Die Neuformulierung der Aufgabenstellung ähnelt der Diskussion mit einem anderen Programmierer, nur dass Sie hier beide Rollen einnehmen.

Die weitergefasste Erkenntnis ist die, dass das Nachdenken über die Problemstellung genauso produktiv oder in einigen Fällen sogar produktiver als das Nachdenken über die Lösung ist. In vielen Fällen ist der richtige Lösungsansatz fast schon die Lösung.

1.1.2 Schiebepuzzles

Schiebepuzzles gibt es in verschiedenen Größen, für die es spezielle Lösungsverfahren gibt, wie Sie später noch sehen werden. Die folgende Aufgabenstellung bezieht sich auf ein Schiebepuzzle mit 3x3 Feldern.

Aufgabe: Verschieben der 8

Ein Gitternetz mit 3x3 Feldern beherbergt 8 Kacheln, die von 1 bis 8 durchnummeriert sind, sowie ein leeres Feld. Eingangs befindet sich das Gitternetz in durcheinandergewürfeltem Zustand. Eine Kachel lässt sich in ein angrenzendes leeres Feld verschieben, wobei das ursprüngliche Feld der Kachel zum leeren Feld wird. Ziel ist es, die Kacheln so zu verschieben, dass sich das Gitternetz in sortiertem Zustand befindet, angefangen mit der Kachel 1 oben links.

Abbildung 1.4 zeigt das angestrebte Ziel dieser Aufgabenstellung. Falls Sie noch nie ein solches Schiebepuzzle ausprobiert haben, sollten Sie sich jetzt die Zeit dafür nehmen. Im Internet finden sich zwar eine Menge Schiebepuzzlesimulatoren, für unsere Zwecke ist es jedoch besser, Spielkarten oder Karteikarten zu verwenden, um damit auf einer Tischplatte zu spielen. In Abbildung 1.5 sehen Sie einen empfohlenen Ausgangszustand.

Abb. 1.4: Der Zielzustand eines Schiebepuzzles mit 8 Kacheln. Das leere Quadrat repräsentiert das leere Feld, in das sich eine angrenzende Kachel verschieben lässt.

Abb. 1.5: Ein spezieller Ausgangszustand des Schiebepuzzles

Dieses Puzzle unterscheidet sich deutlich vom Fuchs-Gans-Getreidesack-Rätsel. Dessen Schwierigkeit rührt daher, dass eine der möglichen Operationen übersehen wird. Das ist beim Schiebepuzzle nicht der Fall. Bei irgendeinem vorgegebenen Zustand gibt es bis zu vier Kacheln, die an das leere Feld angrenzen und in dieses verschoben werden können. Dadurch sind alle möglichen Operationen vollständig beschrieben.

Die Schwierigkeit dieser Aufgabenstellung besteht in der langen Sequenz von Operationen, die zur Lösung erforderlich ist. Eine Abfolge von Verschiebeoperationen mag zwar einige Kacheln in die korrekte Zielposition bewegen, kann aber gleichzeitig andere Kacheln von ihrer Zielposition fortbefördern oder aber einige Kacheln näher an ihre Zielposition bringen, andere hingegen weiter von ihrer Zielposition entfernen. Aus diesem Grund ist es problematisch festzustellen, ob eine bestimmte Operation zu einem Fortschritt in Richtung Zielzustand führt. Wenn sich aber der Fortschritt nicht messen lässt, ist es schwierig, eine Strategie anzugeben. Viele Leute, die sich an einem Schiebepuzzle versuchen, bewegen die Kacheln einfach aufs Geratewohl herum, in der Hoffnung, auf einen Zustand zu treffen, von dem aus ein Weg zum Zielzustand erkennbar ist.

Gleichwohl gibt es Lösungsstrategien für Schiebepuzzles. Lassen Sie uns zur Veranschaulichung eines solchen Ansatzes ein Schiebepuzzle mit kleinerem Gitternetz, das zwar rechteckig, aber nicht quadratisch ist, betrachten.

Aufgabe: Verschieben der 5

Ein Gitternetz mit 2x3 Feldern beherbergt 5 Kacheln, die von 4 bis 8 durchnummeriert sind, sowie ein leeres Feld. Eingangs befindet sich das Gitternetz in durcheinandergewürfeltem Zustand. Eine Kachel lässt sich in ein angrenzendes leeres Feld verschieben, wobei das ursprüngliche Feld der Kachel zum leeren Feld wird. Ziel ist es, die Kacheln so zu verschieben, dass sich das Gitternetz in sortiertem Zustand befindet, angefangen mit der Kachel 4 oben links.

Sie werden bemerkt haben, dass die fünf Kacheln nun von 4 bis 8 statt von 1 bis 5 nummeriert sind. Der Grund dafür wird in Kürze klar werden.

Auch wenn es sich hier um dieselbe grundlegende Aufgabenstellung wie beim Verschieben der 8 handelt, gestaltet sich das Problem mit nur fünf Kacheln erheblich einfacher. Probieren Sie doch mal den in Abbildung 1.6 gezeigten Ausgangszustand aus.

Abb. 1.6: Ausgangszustand für ein verkleinertes Schiebepuzzle mit 2x3 Feldern

Wenn Sie mit diesen Kacheln auch nur wenige Minuten herumspielen, werden Sie wahrscheinlich auf eine Lösung stoßen. Durch das Herumexperimentieren mit Schiebepuzzles, die nur wenige Kacheln besitzen, habe ich eine besondere Fähigkeit entwickelt. Es ist eine Fähigkeit in Verbindung mit einer Beobachtung, die gleich zur Sprache kommen wird, die ich zur Lösung aller Schiebepuzzles verwende.

Ich bezeichne meine Vorgehensweise als „die Eisenbahn”. Sie beruht auf der Feststellung, dass ein geschlossener Ring von Kachelpositionen, der auch das leere Feld umfasst, eine Art „Eisenbahn” (mit den Kacheln als Waggons) bildet, die im Kreis herumfahren kann, ohne dass sich die relative Anordnung der Kacheln zueinander ändert. Abbildung 1.7 zeigt die kleinstmögliche Eisenbahn mit vier Kachelpositionen. Ausgehend von der ersten Anordnung kann Kachel 1 ins leere Feld verschoben werden, Kachel 2 nimmt die von Kachel 1 freigemachte Position ein, und schließlich wird Kachel 3 auf die freigewordene ehemalige Position von Kachel 2 verschoben. Kachel 1 grenzt nun wieder an das leere Feld an, sodass die Eisenbahn ihre Fahrt fortsetzen und somit beliebig auf der „Bahnstrecke” positioniert werden kann.

Abb. 1.7: Eine „Eisenbahn”, eine Folge von Kacheln, die am leeren Feld angrenzend beginnt und sich wie eine Reihe von Waggons durch das Schiebepuzzle schlängelt

Durch die Verwendung einer Eisenbahn können wir eine Sequenz von Kacheln verschieben und dabei deren relative Anordnung zueinander beibehalten. Wenden wir uns nun wieder dem ursprünglichen Gitternetz mit 2x3 Feldern (Abbildung 1.6) zu. Auch wenn noch keine der Kacheln die korrekte Zielposition einnimmt, besitzen doch einige der Kacheln schon den richtigen Nachbarn, neben dem sie sich im Zielzustand befinden sollen. Beispielsweise muss sich die 4 im Zielzustand über der 7 befinden, und derzeit sind die beiden schon Nachbarn. Wie Sie der Abbildung 1.8 unschwer entnehmen können, lässt sich eine Eisenbahn mit sechs Kachelpositionen dazu einsetzen, die Kacheln zur erwünschten Zielposition zu bewegen. Nachdem das erledigt ist, muss nur noch Kachel 8 nach links bewegt werden.

Abb. 1.8: Ausgehend von Zustand 1 sorgt die Fahrt der Eisenbahn zur zweiten Haltestelle dafür, dass wir zu Zustand 2 gelangen. Nun muss nur noch Kachel 8 nach links verschoben werden, um den Zielzustand 3 zu erreichen.

Und diese Vorgehensweise soll es uns ermöglichen, alle möglichen Schiebepuzzles zu lösen? Betrachten wir erneut das ursprüngliche Gitternetz mit 3x3 Feldern. Wir können eine Eisenbahn mit sechs Kachelpositionen verwenden, um die bereits benachbarten Kacheln 1 und 2 so zu positionieren, dass Kachel 2 sich neben Kachel 3 befindet (Abbildung 1.9).

Abb. 1.9: Ausgehend von Zustand 1 fährt die Eisenbahn mehrere Haltestellen weiter, um Zustand 2 zu erreichen.

Die Kacheln 1, 2 und 3 befinden sich nun in aneinander angrenzenden Feldern. Mittels einer Eisenbahn mit acht Kachelpositionen können wir nun die Kacheln 1, 2 und 3 auf die korrekten Zielpositionen bewegen (Abbildung 1.10).

Abb. 1.10: Ausgehend von Zustand 1 fährt die Eisenbahn nach oben links, um die Kacheln 1, 2 und 3 auf den Zielpositionen zu platzieren.

Beachten Sie die Positionen der Kacheln 4 bis 8, die dem Zustand entsprechen, den ich in Abbildung 1.6 für ein 2x3-Gitternetz vorgeschlagen habe. Diese Beobachtung ist ganz entscheidend! Da sich die Kacheln 1, 2 und 3 nun an der richtigen Stelle befinden, können wir das verbleibende Gitternetz als ein separates, kleineres und einfacheres Schiebepuzzle betrachten und lösen. Beachten Sie hier, dass eine ganze Zeile oder Spalte die korrekten Werte enthalten muss, damit diese Methode funktioniert. Wenn die Kacheln 1 und 2 korrekt belegt sind, Kachel 3 jedoch einen falschen Wert enthält, ist es nicht möglich, diese Kachel durch eine andere zu ersetzen, ohne auch mindestens eine der beiden Kacheln 1 und 2 zu bewegen.

Dieses Verfahren lässt sich auch zum Lösen noch größerer Schiebepuzzles einsetzen. Üblicherweise besitzen Schiebepuzzles höchstens 15 Kacheln, verwenden also 4x4 Felder. Diese Puzzles lassen sich schrittweise lösen, indem zunächst die Kacheln 1 bis 4 auf die Zielposition bewegt werden, sodass ein 3x4 Felder großes Gitternetz verbleibt. Nun müssen die Kacheln der linken Spalte auf dieselbe Weise wie zuvor sortiert werden. Es handelt sich jetzt also um ein 3x3-Gitternetz. Dieses Problem haben wir mit der Aufgabe „Verschieben der 8” bereits gelöst.

Gewonnene Erkenntnisse

Welche Einsichten liefert uns das Schiebepuzzle?

Die Anzahl der möglichen Operationen ist so groß, dass es schwierig oder unmöglich ist, anhand des Ausgangszustands einen Lösungsweg anzugeben. Das hindert uns aber nicht daran, Strategien zu entwerfen oder Vorgehensweisen einzusetzen, um die Problemstellung systematisch zu lösen. Beim Lösen von Programmierungsaufgaben sehen wir uns manchmal Situationen gegenüber, in denen ein klarer Weg zur Programmierung der Lösung nicht absehbar ist, aber dieser Umstand darf niemals als Entschuldigung dafür herhalten, auf eine Planung und systematische Ansätze zu verzichten. Es ist allemal besser, eine Strategie zu entwickeln, als das Problem durch Versuch und Irrtum in Angriff zu nehmen.

Ich habe meine „Eisenbahn”-Technik beim Herumtüfteln mit kleinen Schiebepuzzles entwickelt. Beim Programmieren nutze ich oft eine ähnliche Vorgehensweise. Wenn ich mit einem hartnäckigen Problem konfrontiert werde, experimentiere ich mit einer vereinfachten Version davon herum. Diese Experimente führen oft zu wertvollen Erkenntnissen.

Darüber hinaus lässt sich hieraus lernen, dass sich eine Aufgabenstellung mitunter auf eine Art und Weise in Teilaufgaben zerlegen lässt, die nicht unmittelbar ersichtlich ist. Da das Verschieben einer Kachel nicht nur die Kachel selbst betrifft, sondern auch die danach möglichen Operationen beeinflusst, könnte man denken, dass ein Schiebepuzzle in einem einzigen Schritt gelöst werden muss, nicht etappenweise. Die Zeit für die Suche nach einer Möglichkeit, eine Problemstellung in Teilaufgaben zu zerlegen, ist für gewöhnlich gut investiert. Selbst wenn es Ihnen nicht gelingt, eine klare Zerlegung in Teilaufgaben zu finden, gelangen Sie möglicherweise zu anderen Einsichten in das Problem, die Ihnen dabei helfen, es zu lösen. Beim Problemlösen ein bestimmtes Ziel vor Augen zu haben, ist wahllosen Anstrengungen immer vorzuziehen, ob Sie dieses spezielle Ziel nun erreichen oder nicht.

1.1.3 Sudoku

Seit es in Tageszeitungen und Zeitschriften Sudokus gibt und diese auch auf Webseiten und Smartphones zu finden sind, erfreut sich dieses Spiel enormer Beliebtheit. Es gibt verschiedene Sudoku-Varianten, wir werden hier allerdings kurz die traditionelle Version betrachten.

Aufgabe: Vervollständigen eines Sudoku-Quadrats

Einige der Felder eines Gitternetzes mit 9x9 Feldern enthalten einstellige Zahlen (von 1 bis 9). Der Spieler muss die leeren Felder unter Beachtung bestimmter Randbedingungen ausfüllen. In jeder Zeile und Spalte muss jede Ziffer genau einmal vorhanden sein; außerdem muss jedes der markierten 3x3 Felder großen Unterquadrate jede Ziffer von 1 bis 9 genau einmal enthalten.

Falls Sie schon mal ein Sudoku gelöst haben, verfügen Sie vermutlich bereits über verschiedene Strategien, um das Quadrat schnellstmöglich zu vervollständigen. Konzentrieren wir uns auf die entscheidende Strategie beim Start, indem wir uns das Beispiel-Sudoku in Abbildung 1.11 genauer ansehen.

Abb. 1.11: Ein einfaches Sudoku-Rätsel

Der Schwierigkeitsgrad von Sudoku-Rätseln variiert beträchtlich und wird durch die Anzahl der Felder bestimmt, die noch auszufüllen sind. Daran gemessen handelt es sich hier um ein sehr einfaches Rätsel. Da bereits 36 Felder eine Ziffer enthalten, sind lediglich 45 Felder auszufüllen, um das Sudoku zu vervollständigen. Nun stellt sich die Frage: Welches Feld sollen wir als erstes ausfüllen?

Rufen Sie sich die Randbedingungen ins Gedächtnis. Jede der neun Ziffern muss einmal in jeder Zeile, in jeder Spalte und in jedem der 3x3 Felder großen, durch einen fett gedruckten Rand markierten Unterquadrate vorkommen. Durch diese Spielregeln ist festgelegt, wo wir mit dem Ausfüllen anfangen sollten. Im 3x3 Felder großen Quadrat in der Mitte des Sudokus sind bereits acht der neun Felder ausgefüllt. Daher kann das leere Feld im Zentrum des Quadrats nur einen einzigen Wert besitzen, nämlich denjenigen, der in den anderen acht Feldern noch nicht vorhanden ist. Dabei handelt es sich um die 7, die wir nun dort eintragen.

Nachdem dieser Wert eingetragen ist, enthalten sieben der neun Felder der mittleren Spalte einen Wert, sodass nur noch zwei Werte verbleiben, die in dieser Spalte noch nicht vorkommen: 3 und 9. Die Randbedingung für die Spalte erlaubt es, eine der beiden Zahlen in jedes der noch leeren Felder einzutragen. Beachten Sie jedoch, dass die 3 bereits in der dritten Zeile vorkommt und die 9 schon in der siebten Zeile enthalten ist. Die Randbedingung für die Zeile legt daher fest, dass die 9 in die dritte Zeile und die 3 in die siebte Zeile der mittleren Spalte gehört. Diese Lösungsschritte sind in Abbildung 1.12 zusammengefasst.

Abb. 1.12: Die ersten Schritte beim Lösen des Sudoku-Rätsels

Wir wollen an dieser Stelle nicht das gesamte Sudoku lösen, aber diese ersten Schritte machen die wichtige Tatsache deutlich, dass wir nach Feldern suchen, die möglichst wenige Werte annehmen dürfen, idealerweise nur einen einzigen.

Gewonnene Erkenntnisse

Die wichtigste durch das Sudoku-Rätsel gewonnene Erkenntnis ist es, dass wir nach dem Teil der Problemstellung suchen müssen, der den meisten Randbedingungen unterliegt. Zwar sind es oft die Randbedingungen, die eine Aufgabenstellung überhaupt schwierig machen (denken Sie an das Fuchs-Gans-Getreidesack-Rätsel), sie können jedoch auch unsere Überlegungen zur Lösung vereinfachen, weil dadurch Auswahlmöglichkeiten eliminiert werden.

Obwohl wir uns in diesem Buch nicht weiter mit künstlicher Intelligenz beschäftigen werden, möchte ich Folgendes kurz erwähnen. Es gibt im Gebiet der künstlichen Intelligenz eine Regel zum Lösen von Problemstellungen bestimmten Typs, die auf der „Variablen mit den meisten Randbedingungen” beruht. Das bedeutet, dass Sie bei Aufgabenstellungen, in denen Sie versuchen, verschiedenen Variablen unterschiedliche Werte zuzuweisen, um die Randbedingungen zu erfüllen, mit derjenigen Variablen anfangen sollten, die den meisten Randbedingungen unterliegt. Oder anders ausgedrückt: Beginnen Sie mit der Variablen, bei der die Anzahl möglicher Werte am niedrigsten ist.

Es folgt ein Beispiel für derartige Überlegungen. Stellen Sie sich eine Gruppe von Arbeitskollegen vor, die gemeinsam Mittagessen gehen möchten. Sie werden nun gebeten, ein Restaurant auszuwählen, das allen zusagt. Der Haken bei der Sache ist, dass jedes Gruppenmitglied der Gruppe als Ganzem eine Randbedingung auferlegt: Paula ist Vegetarierin, Thomas mag kein chinesisches Essen usw. Wenn Sie schnellstmöglich ein geeignetes Restaurant ausfindig machen möchten, sollten Sie zunächst denjenigen Kollegen befragen, der die schwerwiegendsten Einschränkungen mitbringt. Falls beispielsweise Robert gleich unter einer ganzen Reihe von Lebensmittelallergien leidet, wäre es sinnvoll, eine Liste der Restaurants zu erstellen, in denen er ohne Beschwerden speisen kann, anstatt sich um Thomas zu kümmern, dessen Abneigung gegen chinesisches Essen sich leicht lindern lässt.

Diese Technik lässt sich häufig auch auf Problemstellungen bei der Programmierung anwenden. Wenn ein Teil der Aufgabenstellung sehr vielen Randbedingungen genügen muss, sollten Sie Ihre Arbeit auf diesem Gebiet beginnen, denn Sie können hier Fortschritte erzielen, ohne sich Sorgen machen zu müssen, dass Sie Ihre Zeit mit Arbeiten verbringen, die später möglicherweise zunichtegemacht werden. Eine hiermit in Zusammenhang stehende Konsequenz ist es, mit dem Naheliegenden zu beginnen. Wenn Sie einen Teil der Problemstellung lösen können: Nur zu, erledigen Sie, was Sie können. Vielleicht entdecken Sie beim Betrachten Ihres eigenen Codes etwas, das Ihre Vorstellungskraft so anregt, dass Sie die verbleibende Aufgabe lösen können.

1.1.4 Das Quarrasi-Schloss

Die vorhergehenden Rätsel waren Ihnen möglicherweise bereits bekannt, doch das folgende werden Sie nicht kennen (sofern Sie dieses Buch nicht schon gelesen haben), denn ich habe es mir selbst ausgedacht. Lesen Sie es sorgfältig durch, denn der Wortlaut dieses Rätsels ist ein wenig kompliziert.

Aufgabe: Öffnen des außerirdischen Schlosses

Eine feindselige außerirdische Rasse, die Quarrasi, sind auf der Erde gelandet und haben Sie gefangen genommen. Es ist Ihnen gelungen, die Wächter zu überwältigen, obwohl diese von kolossaler Größe sind und Tentakel besitzen, allerdings müssen Sie zur Flucht aus dem (noch am Boden befindlichen) Raumschiff die äußerst solide Tür öffnen. Die Anleitung zum Öffnen der Tür ist merkwürdigerweise deutschsprachig, aber dennoch alles andere als ein Kinderspiel. Zum Öffnen der Tür müssen Sie drei stabförmige Kratzze entlang einer Führungsschiene vom rechten zum linken Rezeptor verschieben. Dieser befindet sich rund drei Meter entfernt am Ende der Tür.

Nichts leichter als das! Allerdings müssen Sie es vermeiden, den Alarm auszulösen, der folgendermaßen funktioniert: Auf den Kratzzen befinden sich sternförmige Energiekristalle, sogenannte Quinicrys. Jeder Rezeptor besitzt vier Sensoren, die aufleuchten, wenn die Anzahl der Quinicrys in der jeweils darüber befindlichen Röhre eine gerade Zahl ist. Sobald die Anzahl der leuchtenden Sensoren exakt eins ist, wird der Alarm ausgelöst. Beachten Sie, dass die Alarmmechanismen der beiden Rezeptoren voneinander unabhängig sind. Es ist daher nicht möglich, dass bei beiden Rezeptoren gleichzeitig genau ein Sensor aufleuchtet. Die gute Nachricht lautet, dass sich beide Alarmmechanismen stummschalten lassen, wenn ein entsprechender Knopf gedrückt wird. Wenn Sie beide Stummschalter gleichzeitig betätigen könnten, wäre die Aufgabe leicht zu lösen; das können Sie aber nicht, denn Sie verfügen nur über kurze Menschenarme und nicht über die langen Tentakel der Quarrasi.

Nachdem Sie nun all dies wissen: Wie verschieben Sie die Kratzze zum Öffnen der Tür, ohne einen der beiden Alarme auszulösen?

Abbildung 1.13 zeigt den Ausgangszustand, in dem sich alle drei Kratzze im rechten Rezeptor befinden. Zur Verdeutlichung zeigt Abbildung 1.14, wie man es nicht machen sollte: Das Verschieben des obersten Kratzzes in den linken Rezeptor löst im rechten Rezeptor den Alarm aus. Nun denken Sie vielleicht, dass man den Alarm doch stummschalten könnte, aber wenn Sie soeben den oberen Kratzz in den linken Rezeptor verschoben haben, sind Sie drei Meter vom Stummschalter des rechten Rezeptors entfernt.

Abb. 1.13: Der Ausgangszustand des Quarrasi-Schlosses. Sie müssen die drei Kratzz-Stäbe, die sich momentan im rechten Rezeptor befinden, in den linken Rezeptor verschieben, ohne dabei einen der beiden Alarmmechanismen auszulösen. Ein Sensor leuchtet auf, wenn sich in der darüber befindlichen Röhre eine gerade Anzahl sternförmiger Quinicrys befindet, und der Alarm wird ausgelöst, wenn genau einer der Sensoren leuchtet. Durch den Stummschalter lässt sich das Ertönen des Alarms unterdrücken, jedoch nur bei dem Rezeptor, vor dem Sie sich gerade befinden.

Abb. 1.14: Das Quarrasi-Schloss im Alarmzustand. Nachdem Sie den oberen Kratzz in den linken Rezeptor verschoben haben, ist der Stummschalter des rechten Rezeptors außer Reichweite. Der zweite Sensor des rechten Rezeptors leuchtet, da sich in der Röhre über ihm eine gerade Anzahl Quinicrys befindet, und der Alarm wird ausgelöst, wenn genau ein Sensor leuchtet.

Nehmen Sie sich einen Moment Zeit, um diese Problemstellung zu untersuchen, und versuchen Sie, eine Lösung zu finden, bevor Sie fortfahren. Je nach Standpunkt ist diese Aufgabenstellung gar nicht so schwer, wie es aussieht. Ernsthaft, denken Sie darüber nach, bevor Sie weiterlesen!

Haben Sie Überlegungen angestellt? Konnten Sie eine Lösung finden? Es gibt hier zwei mögliche Lösungswege. Einer davon erfolgt durch Versuch und Irrtum: systematisches Ausprobieren der verschiedenen Kratzzverschiebungen und jeweils einen Schritt zurückgehen, wenn ein Alarm ausgelöst wird, bis Sie eine Folge von Verschiebungen finden, die erfolgreich ist.

Der zweite Weg ist die Erkenntnis, dass es sich bei diesem Rätsel um einen Trick handelt. Falls Sie den Trick noch immer nicht entdecken können: Es handelt sich um die Problemstellung des Fuchs-Gans-Getreidesack-Rätsels in einer aufwendigen Verkleidung. Auch wenn die Regeln für das Auslösen des Alarms sehr allgemein formuliert sind, gibt es doch nur einige wenige mögliche Kombinationen für dieses spezielle Schloss. Bei nur drei Kratzzen müssen wir lediglich wissen, welche der Kratzzkombinationen in einem Rezeptor akzeptabel sind. Wenn wir die drei Kratzze als Oben, Mitte und Unten bezeichnen, lösen die Kombinationen Oben/Mitte und Mitte/Unten den Alarm aus. Wenn wir nun Oben in Fuchs, Mitte in Gans und Unten in Getreidesack umbenennen, sind die problematischen Kombinationen mit denen des anderen Rätsels identisch, nämlich Fuchs/Gans und Gans/Getreidesack.

Das Rätsel lässt sich daher auf dieselbe Weise wie das Fuchs-Gans-Getreidesack-Rätsel lösen. Wir verschieben zunächst den mittleren Kratzz (Gans) in den linken Rezeptor. Danach verschieben wir den oberen Kratzz (Fuchs) in den linken Rezeptor und betätigen den linken Stummschalter, während der obere Kratzz (Fuchs) seinen Platz einnimmt. Nun schieben wir den mittleren Kratzz (Gans) zurück in den rechten Rezeptor, dann den unteren Kratzz (Getreidesack) nach links und schließlich den mittleren (Gans) erneut nach links, womit das Schloss geöffnet ist.

Gewonnene Erkenntnisse

Die wichtigste Erkenntnis ist, von welch großer Bedeutung das Erkennen von Analogien ist. Wir sehen hier, dass das Quarrasi-Schloss mit dem Fuchs-Gans-Getreidesack-Rätsel korrespondiert. Wenn wir diese Analogie rechtzeitig erkennen, können wir uns einen Großteil der Arbeit zur Lösung des Problems ersparen, indem wir die Lösung des bereits gelösten Problems übersetzen, anstatt eine neue Lösung zu suchen. Die meisten Analogien beim Problemlösen sind nicht so unmittelbar, sie werden aber immer häufiger auftauchen.

Wenn es Ihnen Schwierigkeiten bereitet hat, die Analogie zwischen der vorliegenden Problemstellung und dem Fuchs-Gans-Getreidesack-Rätsel wahrzunehmen, so liegt dies daran, dass ich absichtlich möglichst viele unerhebliche Details hinzugefügt habe. Die Rahmenhandlung des Quarrasi-Schloss-Rätsels ist völlig irrelevant, ebenso, wie es die Bezeichnungen für die außerirdischen Technologien sind, die nur dazu dienen, das Gefühl der Fremdartigkeit zu steigern. Und auch das Verfahren, eine gerade und ungerade Anzahl beim Auslösen des Alarms einzusetzen, macht die Sache komplizierter, als sie eigentlich ist. Wenn Sie sich die Positionen der Quinicrys genauer ansehen, werden Sie feststellen, dass oberer und unterer Kratzz Gegenstücke voneinander sind und sich daher beim Auslösen des Alarms überhaupt nicht beeinflussen. Der mittlere Kratzz allerdings wechselwirkt mit den anderen beiden.

Machen Sie sich also keine Sorgen, wenn Sie die Analogie nicht erkannt haben. Wenn Sie sich beim Aufspüren von Analogien in Alarmbereitschaft versetzen, werden Sie diese auch eher erkennen.

1.2 Gängige Verfahren zur Problemlösung

Die erläuterten Beispiele demonstrieren viele der entscheidenden Techniken, die beim Problemlösen eingesetzt werden. Im verbleibenden Teil des Buches werden wir spezielle Problemstellungen bei der Programmierung betrachten und herausfinden, wie sich diese lösen lassen, zunächst jedoch benötigen wir einige allgemeine Vorgehensweisen und Prinzipien. Wie Sie sehen werden, gibt es in manchen Aufgabengebieten spezifische Techniken, aber die nachstehenden Regeln treffen auf fast alle Situationen zu. Wenn Sie diese Regeln zu einem üblichen Bestandteil Ihrer Problemlösungsversuche machen, verfügen Sie immer über eine Methode, um ein Problem anzugehen.

1.2.1 Niemals ohne Plan

Hierbei handelt es sich vermutlich um die wichtigste Regel überhaupt. Sie sollten stets einen Plan verfolgen, statt in blinden Aktionismus zu verfallen.

Mittlerweile dürfte Ihnen klar geworden sein, dass es immer möglich ist, einen Plan zu verfolgen. Wenn Sie eine Problemstellung nicht schon im Kopf gelöst haben, können Sie natürlich auch noch keinen Plan aufstellen, wie eine Lösung als Code umzusetzen ist – das kommt später. Sie sollten jedoch schon von Anfang an einen Plan besitzen, wie Sie zu einer Lösung kommen wollen.

Fairerweise muss gesagt werden, dass es erforderlich sein kann, den Plan während dessen Ausführung ändern zu müssen oder ihn zu verwerfen und einen neuen auszuhecken. Warum ist diese Regel dann überhaupt so wichtig? General Dwight D. Eisenhower ist für den Ausspruch „Ich habe stets festgestellt, dass Pläne nutzlos sind, aber eine Planung ist unverzichtbar” bekannt. Er wollte damit sagen, dass Schlachten so chaotisch sind, dass es unmöglich ist, alles vorherzusehen, was geschehen kann, und für alles eine passende Lösung bereitzuhalten. In diesem Sinne also sind Pläne auf dem Schlachtfeld wertlos (ein anderer Feldherr, der Preuße Helmuth von Moltke, sagte bekanntlich: „Kein Plan übersteht den ersten Kontakt mit dem Feind.”). Dennoch kommt keine Armee ohne Planung und Organisation aus. Durch Planung lernt ein General die Fähigkeiten seiner Armee kennen, erfährt, wie die einzelnen Abteilungen der Armee miteinander zusammenarbeiten, usw.

In gleicher Weise benötigen Sie einen Plan zum Lösen eines Problems. Er mag den ersten Kontakt mit dem Feind vielleicht nicht überstehen (möglicherweise muss er verworfen werden, sobald Sie damit anfangen, Code in Ihren Texteditor einzutippen), aber Sie müssen über einen Plan verfügen.

Ohne einen Plan hoffen Sie bestenfalls auf einen Glücksfall, dem Pendant zu den wahllos tippenden Affen, die eines der Werke Shakespeares erzeugen. Glücksfälle sind selten, und selbst in den wenigen Glücksfällen, die es tatsächlich gibt, benötigen Sie einen Plan. Den meisten Menschen ist die Geschichte von der Entdeckung des Penicillins bekannt: Der Forscher Alexander Fleming übersah es eines Abends, eine Petrischale zu verschließen, und entdeckte am nächsten Morgen, dass ein Schimmelpilz die Vermehrung der Bakterien in der Schale gehemmt hatte. Aber Fleming saß nicht herum und wartete auf einen Glücksfall; er hat sorgfältig und planvoll experimentiert und so die Bedeutung seiner Beobachtung in der Petrischale erkannt. (Wenn ich eines Morgens Schimmel auf irgendetwas entdecke, was ich am vorhergehenden Abend vergessen habe, wird das wohl kaum einen bedeutsamen Beitrag zur Wissenschaft leisten.)

Eine Planung erlaubt es Ihnen außerdem, Zwischenziele festzulegen und zu erreichen. Ohne Plan haben Sie nur ein einziges Ziel: das gesamte Problem zu lösen. Solange das Problem noch nicht gelöst ist, werden Sie nicht das Gefühl haben, irgendetwas erreicht zu haben. Wie Sie vermutlich schon selbst erfahren haben, sind viele Programme nicht besonders nützlich, wenn sie nicht schon kurz vor der Vollendung stehen. Nur auf das endgültige Ziel hinzuarbeiten, führt daher unausweichlich zu Frustration, und es gibt bis zum Schluss keine positive Verstärkung Ihrer Anstrengungen. Wenn Sie jedoch einen Plan mit einer Reihe kleinerer Zwischenziele (selbst wenn diese das Hauptproblem vielleicht kaum berühren) aufstellen, werden Sie merkliche Fortschritte auf dem Weg zur endgültigen Lösung machen und das Gefühl haben, dass Sie Ihre Zeit sinnvoll verbracht haben. Am Ende eines jeden Arbeitsabschnitts können Sie einen der Punkte auf Ihrem Plan abhaken und die Zuversicht gewinnen, dass Sie der Lösung näherkommen, statt zunehmend frustrierter zu werden.

1.2.2 Umformulieren der Aufgabenstellung

Wie insbesondere das Fuchs-Gans-Getreidesack-Rätsel zeigt, kann die Umformulierung der Problemstellung wertvolle Einsichten bringen. Eine Problemstellung, die äußerst schwierig erscheint, sieht in manchen Fällen ganz einfach aus, wenn sie anders formuliert oder mittels anderer Begrifflichkeiten beschrieben wird. Die Umformulierung einer Aufgabenstellung gleicht dem Umkreisen eines Hügels, der erstiegen werden soll. Warum nicht nach der einfachsten Aufstiegsmöglichkeit suchen, bevor Sie mit dem Klettern anfangen? Manchmal zeigt uns eine Umformulierung auch, dass das eigentliche Ziel gar nicht dasjenige ist, das wir ursprünglich im Auge hatten. Ich habe von einer Großmutter gelesen, die auf ihre kleine Enkeltochter aufpasste und dabei strickte. Um ihre Strickarbeiten in Ruhe erledigen zu können, setzte sie das Kind neben sich in einen Laufstall. Doch dem Kind gefiel es im Laufstall gar nicht und es weinte unaufhörlich. Die Großmutter versuchte, dem Kind den Laufstall mit allerlei Spielzeug zu versüßen, bis ihr der Gedanke kam, dass der Laufstall an sich das Mittel zum Zweck ist. Das Ziel der Großmutter war es, ungestört stricken zu können. Die Lösung: Das Kind spielt fröhlich auf dem Teppich, während die Großmutter im Laufstall sitzt und strickt. Die Umformulierung kann ein mächtiges Werkzeug sein, doch viele Programmierer nutzen es nicht, weil es nicht unmittelbar mit dem Schreiben von Code oder auch nur dem Entwerfen einer Lösung zu tun hat. Hierbei handelt es sich um einen weiteren Grund dafür, dass ein Plan unabdingbar ist. Ohne Plan besteht das einzige Ziel darin, zu funktionierendem Code zu gelangen, und das Umformulieren nimmt Zeit in Anspruch, die zum Schreiben von Code genutzt werden könnte. Gibt es einen Plan, können Sie „Formales Umformulieren der Problemstellung” als ersten Schritt eintragen – und schon zählt die Umformulierung offiziell als Fortschritt.

Selbst wenn die Umformulierung nicht unmittelbar zu neuen Einsichten führt, kann sie doch auf andere Weise hilfreich sein. Wenn Ihnen beispielsweise (von einem Vorgesetzten oder einem Ausbilder) eine Problemstellung zugewiesen wird, können Sie der betreffenden Person Ihre Umformulierung vorlegen und sich dadurch bestätigen lassen, dass Sie die Aufgabenstellung richtig verstanden haben. Die Umformulierung einer Problemstellung kann außerdem auch ein notwendiger Vorbereitungsschritt zur Verwendung anderer gängiger Techniken sein, wie etwa Reduktion oder Aufgliederung eines Problems.

Allgemeiner betrachtet lassen sich durch eine Umformulierung ganze Aufgabengebiete umgestalten. Bei der Vorgehensweise, die ich für rekursive Lösungen einsetze (dazu später mehr), handelt es sich um eine Methode zum Umformulieren rekursiver Problemstellungen, sodass ich diese genauso wie iterative behandeln kann.

1.2.3 Zerlegung in Teilaufgaben

Findet man einen Weg, um die Problemstellung in mehrere Schritte oder Phasen aufzuteilen, kann dies das Problem erheblich vereinfachen. Lässt sich eine Aufgabenstellung in zwei Teile zerlegen, könnte man denken, dass jeder Teil halb so schwer zu lösen wäre wie die gesamte Aufgabe, aber für gewöhnlich ist es sogar noch einfacher.

Dazu eine Analogie, die Ihnen bekannt sein dürfte, wenn Sie sich schon mit gebräuchlichen Sortieralgorithmen beschäftigt haben. Nehmen wir an, Sie möchten 100 Karteikarten in alphabetischer Reihenfolge in einen Karteikasten einsortieren. Ihre grundlegende Sortiermethode dabei ist das, was als Insertionsort (englisch insertion, Einfügen, und to sort, sortieren) bezeichnet wird: Sie nehmen eine beliebige Karteikarte und legen diese im Karteikasten ab. Die nächste Karteikarte legen Sie dann an der korrekten Position relativ zu der ersten Karteikarte ab und fahren damit fort, die jeweils neue Karteikarte an der richtigen Stelle relativ zu den anderen Karteikarten einzufügen, sodass die Karten im Karteikasten zu jedem Zeitpunkt alphabetisch sortiert sind. Nun stellen Sie sich vor, dass zuvor jemand die Karteikarten in vier ungefähr gleich große Gruppen, A–F, G–M, N–S und T–Z, aufgeteilt hat und Sie auffordert, die vier Gruppen einzeln zu sortieren und dann nacheinander im Karteikasten abzulegen.

Wenn jede Gruppe aus etwa 25 Karteikarten besteht, könnte man meinen, dass das Sortieren von 4 mal 25 Karteikarten in etwa genauso viel Arbeit macht wie das Sortieren einer einzigen Gruppe mit 100 Karteikarten. Tatsächlich ist es aber bedeutend weniger Arbeit, denn die erforderliche Zeit zum Einsortieren einer Karteikarte nimmt mit der Anzahl der bereits vorhandenen Karteikarten zu, schließlich müssen Sie immer im ganzen Karteikasten nachsehen, wo eine Karte hingehört. (Wenn Sie daran zweifeln, denken Sie an eine Extremversion; vergleichen Sie die Vorstellung, 50 Gruppen von 2 Karteikarten zu sortieren, was Ihnen vermutlich in weniger als einer Minute gelingt, mit dem Sortieren einer einzigen Gruppe von 100 Karteikarten.)

In gleicher Weise kann die Aufgliederung einer Problemstellung den Schwierigkeitsgrad oft um eine ganze Größenordnung verringern. Verschiedene Programmiertechniken zu kombinieren, ist allerdings sehr viel kniffliger als die alleinige Verwendung einer Programmtechnik. Ein Codeabschnitt beispielsweise, in dem sich eine Reihe von if-Anweisungen innerhalb einer while-Schleife befinden, die ihrerseits in eine for-Schleife eingebettet ist, wird schwieriger zu programmieren – und zu verstehen – sein, als ein Codeabschnitt, in dem dieselben Kontrollstrukturen hintereinander stehen.

Wir werden im nächsten Kapitel spezielle Verfahren zur Aufgliederung von Problemstellungen betrachten, aber Sie sollten sich dieser Möglichkeit stets bewusst sein. Denken Sie daran, dass bei manchen Aufgabenstellungen, wie etwa im Fall des Schiebepuzzles, die mögliche Unterteilung der Problemstellung oft verborgen ist. Wie Sie bald sehen werden, liefert gelegentlich auch die Reduktion einer Problemstellung, die Zurückführung auf ein anderes Problem, eine mögliche Unterteilung.

1.2.4 Mit Bekanntem anfangen

Erstmaligen Romanautoren wird oft „Schreibe über das, was du kennst” geraten. Das bedeutet nicht, dass Schriftsteller nur Werke über Ereignisse und Personen schreiben sollen, die sie in ihrem eigenem Leben unmittelbar selbst beobachtet haben. Wäre das der Fall, gäbe es weder Fantasy- noch historische Romane und auch so manch anderes weit verbreitete Genre nicht. Gemeint ist vielmehr Folgendes: Je weiter sich ein Autor von den eigenen Erfahrungen entfernt, umso schwieriger kann es werden, darüber zu schreiben.

In ähnlicher Weise sollten Sie beim Programmieren mit Bekanntem anfangen und sich von dort aus weiter vorarbeiten. Sobald Sie beispielsweise eine Aufgabe in Teile zerlegt haben, können Sie weitermachen und alle Programmteile vollenden, von denen Sie bereits wissen, wie sie zu programmieren sind. Eine funktionierende Teillösung kann dabei Anregungen liefern, die zur vollständigen Lösung beitragen. Ständiges Thema beim Lösen von Problemen ist es, wie Sie vielleicht bemerkt haben, brauchbare Fortschritte zu erzielen, um die Zuversicht aufzubauen, dass Sie letzten Endes die Aufgabenstellung vollständig erledigen werden. Wenn Sie mit Bekanntem anfangen, erlangen Sie Zutrauen und den erforderlichen Schwung, der zum Ziel führt.

Der Leitsatz „Mit Bekanntem anfangen” findet auch in solchen Fällen Anwendung, in denen Sie die Problemstellung nicht aufgegliedert haben. Stellen Sie sich vor, jemand hätte eine Liste mit sämtlichen Fähigkeiten beim Programmieren erstellt: eine C++-Klasse schreiben, eine Liste von Zahlen sortieren, den größten Wert in einer verketteten Liste finden usw. Zu jedem Zeitpunkt Ihrer Fortentwicklung als Programmierer wird es in dieser Liste Fähigkeiten geben, die Sie gut beherrschen, andere Fertigkeiten, zu denen Sie nur mit Anstrengung imstande sind, und weitere, die Ihnen noch gänzlich unbekannt sind. Eine bestimmte Problemstellung ist möglicherweise mit den Ihnen eigenen Fähigkeiten vollständig lösbar – oder auch nicht. Sie sollten aber dem vorliegenden Problem unter Verwendung der Ihnen bereits zur Verfügung stehenden Fähigkeiten vollständig auf den Grund gehen, bevor Sie sich nach anderen Lösungswegen umsehen. Wenn wir uns die Fähigkeiten beim Programmieren als Werkzeuge und eine Problemstellung bei der Programmierung als Heimwerkeraufgabe vorstellen, dann sollten Sie versuchen, die Reparatur mit den in Ihrer Garage bereits vorhandenen Werkzeugen durchzuführen, bevor Sie den Baumarkt aufsuchen.

Diese Vorgehensweise entspricht den bereits erläuterten Prinzipien. Sie folgt einem Plan und bringt Ordnung in unsere Bemühungen. Wenn wir die Untersuchung einer Problemstellung mit der Anwendung der uns bereits zur Verfügung stehenden Fähigkeiten beginnen, werden wir möglicherweise mehr über die Aufgabe und deren endgültige Lösung erfahren.

1.2.5 Reduktion der Problemstellung

Wenn Sie einer Problemstellung gegenüberstehen, die Sie nicht lösen können, ermöglicht es dieses Vorgehen, den Umfang des Problems durch Hinzufügen oder Entfernen von Randbedingungen zu reduzieren, sodass Sie zu einer Aufgabenstellung gelangen, für die Sie eine Lösung kennen. Später in diesem Kapitel werden Sie dieses Verfahren in Aktion sehen, doch zunächst ein einfaches Beispiel. Angenommen, Sie erhalten eine Reihe von Koordinaten im dreidimensionalen Raum. Sie sollen die Koordinaten finden, die einander am nächsten liegen. Falls Ihnen nicht sofort klar ist, wie das zu lösen ist: Es gibt mehrere Möglichkeiten, das Problem auf einfachere Aufgaben zurückzuführen. Was wäre beispielsweise, wenn es sich statt um dreidimensionale um zweidimensionale Koordinaten handeln würde? Und wenn das nicht hilft, was wäre, wenn es sich um Punkte auf einer einzigen Linie handeln würde, sodass die Koordinaten nur einzelne Zahlen (etwa C++-Gleitkommazahlen doppelter Genauigkeit) sind? Nun ist die eigentliche Aufgabe, in einer Liste von Zahlen das Zahlenpaar zu finden, bei dem der Absolutbetrag der Differenz am kleinsten ist.

Sie könnten auch eine Reduktion des Problems vornehmen, indem Sie die Koordinaten zwar im dreidimensionalen Raum belassen, aber statt einer beliebig langen Folge nur drei Werte verwenden. Ein Algorithmus, der den kleinsten Abstand zwischen allen Koordinaten findet, ist nicht erforderlich. Es müssen nur noch die Koordinaten A und B, B und C sowie A und C miteinander verglichen werden.

Diese Reduktionen vereinfachen die Problemstellung auf verschiedene Weise. Im ersten Fall entfällt die Notwendigkeit, den Abstand zwischen Punkten im dreidimensionalen Raum zu berechnen. Vielleicht wissen wir noch nicht, wie das geht, aber bevor wir das herausfinden, machen wir dennoch Fortschritte in Richtung einer Lösung. Im Gegensatz dazu konzentriert sich die zweite Reduktion nahezu vollständig darauf, die Distanz zwischen dreidimensionalen Koordinaten zu berechnen, beseitigt aber das Problem, das Minimum einer beliebig langen Folge von Werten zu finden.

Um die ursprüngliche Aufgabe zu lösen, sind natürlich letztlich die in beiden Reduktionen eingesetzten Fähigkeiten erforderlich. Dennoch erlaubt uns die Reduktion, an einem vereinfachten Problem zu arbeiten, sogar wenn es nicht gelingt, die Problemstellung in einzelne Schritte zu zerlegen. Faktisch handelt es sich hier um einen absichtlichen, wenn auch vorübergehenden, Kobayashi Maru. Uns ist klar, dass wir nicht am vollständigen Problem arbeiten, aber das vereinfachte Problem besitzt genug Gemeinsamkeiten mit dem vollständigen Problem, um es uns zu ermöglichen, Fortschritte auf dem Weg zur endgültigen Lösung zu machen. Es geschieht sehr oft, dass Programmierer feststellen, über alle erforderlichen Fähigkeiten zum Lösen einer Aufgabenstellung zu verfügen. Durch das Schreiben des Codes zum Lösen einzelner Aspekte der Problemstellung erkennen sie dann, wie sich die einzelnen Teile zu einem einheitlichen Ganzen kombinieren lassen.

Die Reduktion einer Problemstellung erlaubt es uns außerdem, festzustellen, wo genau die verbleibenden Schwierigkeiten liegen. Programmieranfänger sind oft auf die Hilfe erfahrener Programmierer angewiesen, jedoch kann dies für alle Beteiligten zu einem frustrierenden Erlebnis werden, wenn der sich mühende Programmierer nicht in der Lage ist, genau zu beschreiben, wobei er Hilfe braucht. Man sollte sich keinesfalls darauf beschränken zu sagen: „Hier ist mein Programm, das nicht funktioniert. Warum nicht?” Bei der Reduktion der Problemstellung kann die erforderliche Hilfe genau eingegrenzt werden: „Hier ist der Code, den ich geschrieben habe. Wie man sieht, weiß ich schon, wie der Abstand zwischen zwei dreidimensionalen Koordinaten berechnet wird, und ich weiß auch, wie ich prüfen kann, ob ein Abstand kürzer als ein anderer ist. Aber ich kann keine allgemeine Lösung finden, um das Koordinatenpaar mit minimalem Abstand zu berechnen.”

1.2.6 Analogien suchen

Für unsere Zwecke beschreibt eine Analogie eine Ähnlichkeit zwischen der vorliegenden Problemstellung und einem bereits gelösten Problem. Diese Übereinstimmung lässt sich zur Lösung des neuen Problems ausnutzen. Die Ähnlichkeit kann viele Formen annehmen. Manchmal bedeutet sie, dass es sich tatsächlich um ein und dieselbe Problemstellung handelt. Dieser Fall liegt beim Fuchs-Gans-Getreide-Rätsel und dem Quarrasi-Schloss vor.

Die meisten Analogien sind jedoch nicht derart unmittelbar. Manchmal betrifft die Ähnlichkeit auch nur Teile der Aufgabenstellung. Zwei Aufgabenstellungen zur Verarbeitung von Zahlen beispielsweise könnten sich grundlegend unterscheiden, mit der Ausnahme, dass beide mit Zahlen rechnen, die eine höhere Genauigkeit erfordern, als die verfügbaren Gleitkommadatentypen bieten. Beim Lösen des vollständigen Problems ist diese Analogie keine Hilfe, wenn Sie aber schon eine Möglichkeit gefunden haben, mit dem Problem der zu geringen Genauigkeit fertig zu werden, können Sie beide Problemstellungen auf die gleiche Weise lösen.

Zwar ist das Erkennen von Analogien die wichtigste Methode, mittels derer Sie Ihr Tempo und Können beim Problemlösen steigern werden, es ist jedoch gleichzeitig auch diejenige Fähigkeit, die am schwierigsten zu entwickeln ist. Der Grund für diese anfängliche Schwierigkeit ist, dass Sie nicht nach Analogien suchen können, wenn Sie nicht schon auf einen großen Schatz früherer Lösungen zurückgreifen können.

An dieser Stelle versuchen angehende Programmierer häufig, eine Abkürzung zu finden, indem sie nach Code suchen, der dem benötigten Code ähnelt, um diesen dann zu modifizieren. Dies zu tun, ist allerdings gleich aus mehreren Gründen ein Fehler. Erstens werden Sie den Code nicht hundertprozentig verstehen und verinnerlichen, wenn Sie ihn nicht vollständig selbst geschrieben haben. Vereinfacht ausgedrückt ist es sehr schwierig, ein Programm korrekt anzupassen, das Sie nicht komplett verstehen. Sie müssen den Code nicht selbst geschrieben haben, um ihn vollständig zu verstehen, aber wenn Sie den Code nicht selber hätten schreiben können, wird Ihr Verständnis notwendigerweise begrenzt bleiben. Zweitens ist jedes erfolgreiche Programm, das Sie schreiben, nicht nur die Lösung für ein aktuelles Problem, sondern potenzielle Quelle für Analogien zum Lösen künftiger Probleme. Je mehr Sie sich jetzt auf den Code anderer Programmierer verlassen, umso mehr werden Sie sich auch in Zukunft darauf verlassen müssen. Die sinnvolle bzw. nicht sinnvolle Wiederverwendung von Code kommt in Kapitel 7 noch ausführlich zur Sprache.

1.2.7 Experimentieren

Manchmal ist die beste Methode, um Fortschritte zu erzielen, einfach etwas auszuprobieren und das Resultat in Augenschein zu nehmen. Beachten Sie aber, dass Experimentieren und Raten zwei Paar Schuhe sind. Beim Raten geben Sie auf gut Glück etwas Code ein und hoffen, dass er funktioniert, ohne dabei einen sonderlich festen Glauben daran zu haben. Ein Experiment ist hingegen ein gesteuerter Vorgang. Sie stellen eine Hypothese darüber auf, was beim Ausführen eines bestimmten Codeabschnitts passieren wird, probieren es aus und überprüfen dann, ob Ihre Hypothese richtig war. Durch diese Beobachtungen erhalten Sie Informationen, die Ihnen dabei helfen, das ursprüngliche Problem zu lösen.

Experimentieren ist insbesondere beim Umgang mit APIs oder Klassenbibliotheken hilfreich. Angenommen, Sie schreiben ein Programm, das eine Bibliotheksklasse verwendet, die einen Vektor repräsentiert (in diesem Zusammenhang ein eindimensionales Array, das automatisch wächst, wenn ihm weitere Elemente hinzugefügt werden). Sie haben diese Vektorklasse aber vorher noch nie benutzt und sind sich nicht sicher, was geschieht, wenn ein Element des Vektors gelöscht wird. Anstatt sich der Lösung des Problems weiter zu nähern, obwohl Ihnen eine gewisse Unsicherheit im Kopf herumspukt, könnten Sie ein kleines separates Programm erstellen, nur um mit der Vektorklasse herumzuspielen und speziell die Situationen auszutesten, die Ihnen Kopfschmerzen bereiten. Wenn Sie einige Zeit mit dem Programm „Vektor-Demo” verbracht haben, könnte es Ihnen als Referenz dienen, wenn Sie die Klasse später erneut verwenden.

Andere Arten des Experimentierens sind dem Debuggen ähnlich. Nehmen wir an, ein bestimmtes Programm liefert Ausgaben nicht in der erwarteten Reihenfolge, sondern verkehrt herum. Wenn es sich beispielsweise um numerische Ausgaben handelt, sind die Zahlen an sich zwar korrekt, erscheinen jedoch in umgekehrter Reihenfolge. Wenn Ihnen nach der Überprüfung Ihres Codes nicht klar ist, warum dies geschieht, könnten Sie Ihren Code so abändern, dass er absichtlich Ausgaben in umgekehrter Reihenfolge erzeugt (etwa eine Schleife in der anderen Richtung durchlaufen). Die dadurch resultierenden – oder fehlenden – Änderungen in der Ausgabe könnten auf einen Fehler in Ihrem ursprünglichen Code hinweisen oder eine Lücke in Ihrem Verständnis offenbaren. Wie dem auch sei, Sie sind einer Lösung jedenfalls ein Stück näher gekommen.

1.2.8 Nicht entmutigen lassen

Bei der letzten Technik handelt es sich weniger um ein Verfahren als um einen Leitsatz: Lassen Sie sich nicht entmutigen. Wenn Sie frustriert sind, werden Sie nicht so klar denken wie sonst, nicht so effizient arbeiten wie sonst und alles wird länger dauern und Ihnen schwieriger erscheinen. Und was noch schlimmer ist: Frustration tendiert dazu, selbstverstärkend zu sein, und was als kleines Ärgernis beginnt, wird zu ausgemachtem Zorn.

Wenn ich Programmieranfängern diesen Ratschlag gebe, erwidern sie häufig, dass sie mir zwar im Prinzip zustimmen, sich aber außerstande sehen, das Maß ihrer Frustration zu steuern. Käme die Aufforderung an einen Programmierer, bei ausbleibendem Erfolg nicht frustriert zu sein, nicht der Bitte an einen kleinen Jungen gleich, nicht aufzuschreien, wenn er barfuß auf eine Heftzwecke tritt? Die Antwort lautet Nein. Wenn jemand auf eine Heftzwecke tritt, werden durch das zentrale Nervensystem augenblicklich starke Impulse gesendet, auf die tiefer liegende Ebenen des Gehirns ansprechen. Solange Sie nicht im Voraus wissen, dass Sie im Begriff sind, auf eine Heftzwecke zu treten, ist es unmöglich, rechtzeitig zu reagieren, um die automatische Rückmeldung des Gehirns zu unterdrücken. Dem kleinen Jungen sehen wir seinen Aufschrei daher nach.

Beim Programmierer sieht die Sache anders aus. Auch wenn ich nun Gefahr laufe, wie ein Motivationsguru zu klingen: Ein frustrierter Programmierer reagiert nicht auf einen von außen kommenden körperlichen Reiz. Ein frustrierter Programmierer ist auch nicht auf den Quellcode auf seinem Bildschirm wütend (obwohl manche ihrem Ärger manchmal in den Kommentaren darin Luft machen). In Wahrheit ist der frustrierte Programmierer auf sich selbst wütend. Die Quelle der Frustration ist zugleich deren Ziel, nämlich der Verstand des Programmierers.

Wenn Sie es sich erlauben, frustriert zu sein – ich verwende das Wort „erlauben” ganz bewusst –, geben Sie sich praktisch selbst einen Freibrief, um weiterhin zu scheitern. Nehmen wir an, dass Sie an einem hartnäckigen Problem arbeiten und spüren, wie allmählich die Frustration in Ihnen aufsteigt. Stunden später blicken Sie auf einen Nachmittag mit Zähneknirschen und vor Wut zerbrochenen Bleistiften zurück, und Sie sagen sich, dass Sie echte Fortschritte hätten erzielen können, wenn Sie dazu fähig gewesen wären, sich zu beruhigen. Tatsächlich haben Sie möglicherweise beschlossen, dass es einfacher ist, dem Ärger nachzugeben, als sich dem schwierigen Problem zu stellen.

Letzten Endes ist es Ihre Entscheidung, ob Sie der Frustration ausweichen können. Es gibt jedoch einige hilfreiche Überlegungen. Vergessen Sie vor allem die erste Regel nicht, nämlich stets über einen Plan zu verfügen, und dass es zwar das Hauptziel des Plans ist, Code zu schreiben, der die ursprüngliche Problemstellung löst, dieses aber nicht der einzige Schritt des Plans ist. Wenn Sie also über einen Plan verfügen und diesem folgen, dann machen Sie Fortschritte – daran müssen Sie schon glauben. Wenn Sie aber alle Schritte des ursprünglichen Plans abgearbeitet haben und sich noch immer nicht dafür bereit fühlen, mit dem Programmieren anzufangen, ist es an der Zeit, einen neuen Plan aufzustellen.

Sollten Sie außerdem feststellen, dass alles darauf hinausläuft, dass Sie frustriert werden, wenn Sie nicht mal Pause machen, dann sollten Sie diese Pause besser einlegen. Ein guter Trick ist es, stets mehr als eine Aufgabe bereitzuhalten, an der Sie arbeiten können. Wenn Sie bei einer bestimmten Aufgabe in der Klemme sind, können Sie sich dann einer anderen Aufgabe widmen. Beachten Sie hier, dass Sie nach erfolgreicher Aufgliederung einer Problemstellung in Teilaufgaben diese Vorgehensweise auch bei einer einzelnen Problemstellung einsetzen können. Dazu müssen Sie nur den Teil des Problems, bei dem Sie ins Stocken geraten sind, aus Ihren Gedanken verbannen und sich dann anderen Aufgaben zuwenden. Falls es keine sonstigen Aufgaben gibt, die Sie in Angriff nehmen könnten, erheben Sie sich von Ihrem Stuhl und machen Sie irgendetwas anderes, das Ihren Kreislauf in Schwung hält und Ihnen keine Kopfschmerzen bereitet. Unternehmen Sie einen Spaziergang, waschen Sie die Wäsche oder machen Sie Ihre Dehnübungen. (Falls Sie als Programmierer tätig sind und den ganzen Tag vor dem Computer sitzen, kann ich es nur wärmstens empfehlen, Dehnübungen zu machen!)

1.3 Übungen

Halten Sie sich vor Augen, dass es zum gründlichen Lernen unabdingbar ist, das Gelernte in die Praxis umzusetzen. Sie sollten daher möglichst viele der Übungen durcharbeiten. Im ersten Kapitel geht es natürlich noch nicht um Programmierung, aber dennoch möchte ich Sie ermuntern, einige der Übungen auszuprobieren. Stellen Sie sich diese Fragen als Fingerübungen eines Pianisten zum Aufwärmen vor, bevor das eigentliche Klavierkonzert beginnt.

1.1

Versuchen Sie, ein Sudoku mittleren Schwierigkeitsgrads zu lösen. Sie finden diese überall im Internet und vermutlich auch in Ihrer Lokalzeitung. Experimentieren Sie mit verschiedenen Strategien und notieren Sie sich die Resultate. Können Sie einen allgemeinen Plan zum Lösen von Sudokus aufstellen?

1.2

Betrachten Sie eine Schiebepuzzlevariante, bei der sich auf den Kacheln keine Zahlen befinden, sondern ein Bild. Inwieweit wird dadurch der Schwierigkeitsgrad erhöht und warum ist dem so?

1.3

Entwickeln Sie eine Strategie zum Lösen von Schiebepuzzles, die sich von meiner unterscheidet.

1.4

Suchen Sie nach antiquierten Varianten des Fuchs-Gans-Getreidesack-Rätsels und versuchen Sie, diese zu lösen. Viele der großartigen Rätsel wurden von Sam Loyd erfunden oder bekannt gemacht, suchen Sie daher am besten nach seinem Namen. Überlegen Sie sich außerdem, wie Sie das Rätsel vereinfachen könnten, sobald Sie die Lösung gefunden haben (oder aufgeben und diese nachlesen). Was müssten Sie ändern? Die Randbedingungen oder nur den Wortlaut des Rätsels?

1.5

Versuchen Sie, detaillierte Strategien für andere traditionelle Rätselspiele mit Papier und Bleistift zu entwickeln, wie beispielsweise Kreuzworträtsel. Wo sollte man anfangen? Was ist zu tun, wenn man nicht weiterkommt? Selbst so einfache Rätselspiele wie das in vielen Zeitungen vorhandene „Jumble” sind beim Nachdenken über Strategie nützlich.

Kapitel 2: Wahre Rätsel

In diesem Kapitel werden wir uns erstmals mit dem eigentlichen Code beschäftigen. Während in noch folgenden Kapiteln das Wissen fortgeschrittener Anfänger erforderlich ist, sind die hier nötigen Programmierkenntnisse so gering wie möglich gehalten. Das soll nicht heißen, dass alle Rätsel leicht sein werden, sondern dass Sie in der Lage sein sollen, sich auf das Lösen von Problemen zu konzentrieren, nicht auf die Programmsyntax. Hier geht es um das Lösen von Problemen in reinster Form. Sobald Sie herausgefunden haben, was Sie erledigen möchten, ist es problemlos möglich, Ihre Überlegungen in C++-Code zu übertragen. Denken Sie daran, dass die Lektüre dieses Buches für sich allein genommen nur wenig Nutzen bringt. Sie sollten alle Aufgabenstellungen, die Ihnen nicht als trivial erscheinen, wenn wir sie untersuchen, sorgfältig durcharbeiten und versuchen, selbst eine Lösung zu finden, bevor Sie meinen Ansatz nachlesen. Versuchen Sie sich auch an den Übungen am Ende des Kapitels, bei denen es sich in vielen Fällen um Ausweitungen der in diesem Kapitel erläuterten Problemstellungen handelt.

2.1 Verwendete C++-Syntax

In diesem Kapitel wird grundlegendes C++ verwendet, das Ihnen bereits bekannt sein sollte. Dazu gehören die Kontrollstrukturen if, for, while, do-while und switch. Möglicherweise fühlen Sie sich noch nicht so richtig wohl dabei, Code zu schreiben, um Problemstellungen mit diesen Anweisungen zu lösen, aber darum geht es schließlich in diesem Buch. Jedenfalls sollten Sie die Syntax dieser Anweisungen kennen oder wenigstens ein gutes Buch über C++ zur Hand haben.

Darüber hinaus sollten Sie wissen, wie Funktionen programmiert und aufgerufen werden. Um die Sache nicht unnötig zu verkomplizieren, verwenden wir zur Ein- und Ausgabe die Standard-I/O-Streams cin und cout. Um diese Datenströme verwenden zu können, muss die entsprechende Header-Datei iostream eingebunden werden. Fügen Sie dann using-Anweisungen für die beiden Standarddatenstromobjekte hinzu:

#include <iostream>
using std::cin;
using std::cout;

Der Kürze halber kommen diese Anweisungen in den Code-Listings nicht vor. Ihr Vorhandensein wird jedoch bei allen Programmen, die sie verwenden, vorausgesetzt.

2.2 Ausgabe von Mustern

In diesem Kapitel werden wir drei Hauptaufgabenstellungen durcharbeiten. Dabei werden wir ausgiebigen Gebrauch von der Zerlegung der Aufgabenstellung und der Reduktion machen, und es werden daher von jeder der Hauptaufgaben mehrere Unteraufgaben abgeleitet. Im ersten Abschnitt werden wir eine Reihe von Programmen ausprobieren, die bei der Ausgabe Muster regelmäßiger Formen erzeugen. Durch Programme dieser Art wird die Fähigkeit geschult, Schleifen zu programmieren.

Aufgabe: Halbiertes Quadrat

Schreiben Sie ein Programm, das nur zwei Ausgabeanweisungen enthält, nämlich cout << "#" und cout << "\n", um ein Muster von Doppelkreuzen auszugeben, das die Form eines halbierten, 5x5 Felder großen Quadrats besitzt (also wie ein Dreieck aussieht):

#####
####
###
##
#

Dies ist ein weiteres schönes Beispiel für die Bedeutung von Randbedingungen. Wird die Randbedingung ignoriert, dass nur die beiden Anweisungen zur Ausgabe eines einzelnen Doppelkreuzes und eines Zeilenendes benutzt werden dürfen, können wir einen Kobayashi Maru schreiben und die Aufgabe ist trivial. Um hingegen der Randbedingung zu genügen, müssen wir zur Lösung der Aufgabe Schleifen verwenden.

Vielleicht haben Sie die Lösung schon vor Augen, aber lassen Sie uns annehmen, dass dem nicht so ist. Probieren wir zunächst eine Reduktion. Wie können wir die Problemstellung auf eine leicht lösbare Aufgabe zurückführen? Was wäre, wenn es sich beispielsweise nicht um ein halbiertes, sondern um ein ganzes Quadrat handeln würde?

Aufgabe: Quadrat (Halbiertes Quadrat, Reduktion)

Schreiben Sie ein Programm, das nur zwei Ausgabeanweisungen enthält, nämlich cout << "#" und cout << "\n", um ein Muster von Doppelkreuzen auszugeben, das die Form eines 5x5 Felder großen Quadrats besitzt:

#####
#####
#####
#####
#####

Für den Anfang ist dies vielleicht schon ausreichend, aber wir wollen annehmen, dass wir auch diese Aufgabe nicht lösen können. Wir könnten eine weitere Reduktion durchführen und statt eines Quadrats nur eine einzelne Zeile von Doppelkreuzen erzeugen.

Aufgabe: Zeile (Halbiertes Quadrat, weitere Reduktion)

Schreiben Sie ein Programm, das nur zwei Ausgabeanweisungen enthält, nämlich cout << "#" und cout << "\n", um eine Zeile von fünf Doppelkreuzen auszugeben:

#####

Nun liegt eine einfache Aufgabe vor, die wir mit einer for-Schleife lösen können:

for {int hashNum = 1; hashNum <= 5; hashNum++} {
   cout << "#";
}
cout << "\n";

Wenden wir uns hiervon ausgehend der vorhergehenden Reduktion zu, dem vollständigen Quadrat. Das Quadrat besteht aus fünf Wiederholungen der Zeile mit fünf Doppelkreuzen. Wie die Wiederholung von Code durchgeführt wird, ist uns bekannt: Wir programmieren eine Schleife. Wir machen also aus unserer einfachen Schleife eine doppelte:

for {int row = 1; row <= 5; row++} {
   for {int hashNum = 1; hashNum <= 5; hashNum++} {
      cout << "#";
   }
   cout << "\n";
}

Wir haben den gesamten Code des vorhergehenden Listings in eine neue Schleife eingebettet, sodass er fünf Mal wiederholt wird und fünf Zeilen erzeugt, die aus jeweils fünf Doppelkreuzen bestehen. Wir kommen der endgültigen Lösung näher. Wie müssen wir den Code modifizieren, damit er ein halbiertes Quadrat erzeugt? Wenn wir uns das Listing näher ansehen und mit dem erwünschten Muster vergleichen, stellen wir fest, dass der Ausdruck mit der Bedingung hashNum <= 5 problematisch ist. Durch diese Bedingung werden in jeder Zeile fünf Doppelkreuze erzeugt. Wir benötigen einen Mechanismus, der die Anzahl der in jeder Zeile erzeugten Zeichen so korrigiert, dass die erste Zeile fünf Zeichen enthält, die zweite Zeile vier Zeichen enthält usw.

Lassen Sie uns mit einer weiteren Reduktion experimentieren, um herauszufinden, wie sich das bewerkstelligen lässt. Es ist wie immer am einfachsten, den problematischen Teil der Aufgabenstellung isoliert zu betrachten. Vergessen Sie für einen Augenblick die Doppelkreuze und konzentrieren Sie sich auf Zahlen.

Aufgabe: Herunterzählen durch Hochzählen

Schreiben Sie eine Zeile Code, die im nachstehenden Listing an der mit Ausdruck gekennzeichneten Stelle eingefügt wird. Das Programm gibt die Zahlen 5 bis 1 (in dieser Reihenfolge) aus, wobei jede Zahl in einer eigenen Zeile steht:

for {int row = 1; row <= 5; row++} {
   cout << Ausdruck;
}

Wir müssen also einen Ausdruck finden, der den Wert 5 annimmt, wenn row = 1 ist, den Wert 4, wenn row = 2 ist, usw. Wenn wir einen Ausdruck suchen, dessen Wert vermindert wird, wenn row erhöht wird, liegt es nahe, vor der Variablen row ein Minuszeichen einzufügen, indem wir row mit -1 multiplizieren. Dadurch werden absteigende Zahlen erzeugt, aber noch nicht die Wunschwerte. Wir sind vielleicht schon näher an der Lösung dran als gedacht. Wie groß ist die Differenz zwischen den Wunschwerten und den Zahlen, die durch die Multiplikation von row mit -1 erzeugt werden? In Tabelle 2.1 finden Sie eine Auswertung dieser Überlegung.

Zeile | Wunschwert | row * (-1) | Differenz vom Wunschwert
1 | 5 | -1 | 6
2 | 4 | -2 | 6
3 | 3 | -3 | 6
4 | 2 | -4 | 6
5 | 1 | -5 | 6

Tabelle 2.1: Berechnung des Wunschwerts aus der Variablen row

Die Differenz ist eine Konstante, nämlich 6. Der gesuchte Ausdruck lautet also row * (-1) + 6. Durch ein wenig Algebra wird daraus 6 - row. Probieren wir das aus:

for {int row = 1; row <= 5; row++} {
   cout << 6 – row << "\n";
}

Klasse, es funktioniert! Hätte es nicht funktioniert, wäre unser Fehler aufgrund der sorgfältig vorgenommenen einzelnen Schritte wohl eher unbedeutend. Schließlich ist es sehr einfach, mit einem so kurzen und simplen Codeabschnitt zu experimentieren. Verwenden wir nun den Ausdruck, um die innere Schleife zu begrenzen:

for {int row = 1; row <= 5; row++} {
   for {int hashNum = 1; hashNum <= 6 - row; hashNum++} {
      cout << "#";
   }
   cout << "\n";
}

Bei der Reduktion einer Problemstellung sind mehr Schritte erforderlich, um von der Beschreibung der Aufgabe zum vollständigen Programm zu gelangen, aber die einzelnen Schritte sind einfacher. Stellen Sie sich einen Flaschenzug vor, mit dem Sie einen schweren Gegenstand heben. Um den Gegenstand ein kleines Stück hochzuheben, müssen Sie eine viel längere Strecke am Seil ziehen, müssen dafür aber nur wenig Kraft aufwenden.

Lassen Sie uns nun ein weiteres Muster in Angriff nehmen.

Aufgabe: Hochkant stehendes Dreieck

Schreiben Sie ein Programm, das nur zwei Ausgabeanweisungen enthält, nämlich cout << "#" und cout << "\n", um ein Muster von Doppelkreuzen auszugeben, das die Form eines hochkant stehenden Dreiecks besitzt:

#
##
###
####
###
##
#

Wir werden jetzt nicht sämtliche Schritte der vorhergehenden Aufgabenstellung erneut durchgehen, denn das ist gar nicht notwendig. Die Aufgabenstellung „Hochkant stehendes Dreieck” weist eine Analogie zur Aufgabe „Halbiertes Quadrat” auf und wir können das dort Gelernte nun anwenden. Denken Sie an den Leitsatz „Mit Bekanntem anfangen”. Stellen wir also zunächst eine Liste der Kenntnisse zusammen, die wir bei der Aufgabe „Halbiertes Quadrat” erworben haben und die wir auf das neue Problem anwenden können:

Anzeige einer Zeile mit einer bestimmten Anzahl von Zeichen durch Verwendung einer Schleife

Anzeige mehrerer Zeilen durch Verwendung verschachtelter Schleifen

Erzeugen einer veränderlichen Anzahl von Zeichen pro Zeile durch Verwendung eines algebraischen Ausdrucks anstelle einer Konstanten

Ermitteln des korrekten algebraischen Ausdrucks durch Experimentieren und Auswertung

Abbildung 2.1 fasst den gegenwärtigen Stand der Dinge zusammen. Die obere Zeile zeigt die Aufgabe „Halbiertes Quadrat”. Sie sehen dort in Spalte (a) das erwünschte Muster aus Doppelkreuzen, in Spalte (b) das Muster der Zeile, in Spalte (c) das Quadrat und in Spalte (d) die Folge der Zahlen, durch die das Quadratmuster in das Muster des halbierten Quadrats überführt wird. Die untere Zeile zeigt die neue Aufgabe „Hochkant stehendes Dreieck” mit dem Wunschmuster (e), die Zeile (f), das Quadratmuster (g) und die entsprechende Zahlenfolge (h).

Abb. 2.1: Die verschiedenen Komponenten, die zur Lösung der Mustererzeugungsaufgaben benötigt werden

Zum jetzigen Zeitpunkt bereitet es keine Schwierigkeiten, (f) zu erzeugen, da es mit (b) nahezu identisch ist. Und auch (g) sollte sich erzeugen lassen, da es (c) mit einigen Zeilen mehr und einem Zeichen pro Zeile weniger entspricht. Wenn jetzt noch der algebraische Ausdruck vom Himmel fällt, der die Zahlenfolge (h) liefert, hält uns nichts mehr davon ab, das Wunschmuster (e) zu erzeugen.

Der größte Teil der Denkarbeit zur Lösung der Aufgabe „Hochkant stehendes Dreieck” ist also schon erledigt. Darüber hinaus wissen wir genau, was noch fehlt: der Ausdruck zum Erzeugen der Zahlenfolge (h). Folglich sollten wir uns darauf konzentrieren. Wir könnten nun mit dem fertigen Code der Aufgabe „Halbiertes Quadrat” herumexperimentieren, bis wir die gewünschte Zahlenfolge erzeugen können oder aber einen Tipp abgegeben, indem wir eine Tabelle nach dem Vorbild von Tabelle 2.1 erstellen, um zu sehen, ob das unserer Kreativität auf die Sprünge hilft.

Lassen Sie es uns diesmal mit Experimentieren versuchen. Bei der Aufgabe „Halbiertes Quadrat” hat es bestens funktioniert, die Zeilennummer von einer größeren Zahl zu subtrahieren. Testen wir also, welche Zahlen wir erhalten, wenn wir row in einer Schleife die Zahlenwerte von 1 bis 7 durchlaufen lassen und row von 8 subtrahieren. Das Resultat sehen Sie in Abbildung 2.2 in der Spalte (b). Das ist jedoch nicht das erwünschte Ergebnis. Wie fahren wir nun fort? Bei der vorhergehenden Aufgabe benötigten wir eine Zahl, die nicht erhöht, sondern vermindert wird. Wir haben daher die Schleifenvariable von einer größeren Zahl abgezogen. Bei dieser Aufgabe benötigen wir hingegen zunächst ansteigende, dann fallende Werte. Wäre es sinnvoll, von einem dazwischen liegenden Wert zu subtrahieren? Wenn wir 8 - row im Code durch 4 - row ersetzen, erhalten wir als Ergebnis die Spalte (c) in Abbildung 2.2. Das stimmt zwar noch immer nicht, sieht aber nach einem brauchbaren Verfahren aus, wenn man von den Minuszeichen vor den letzten drei Zahlen einmal absieht. Verwenden wir also probehalber die Funktion zur Berechnung des Absolutbetrags. Der Ausdruck abs(4 - row) liefert als Ergebnis die Spalte (d) in Abbildung 2.2. Wir sind ganz nah dran – ich kann es fast schon riechen! Leider nur bewegen sich die Zahlen zunächst nach unten, dann nach oben, obwohl wir genau das Gegenteil benötigen. Wie also gelangen wir von der erzeugten Zahlenfolge zur tatsächlich erforderlichen?

Abb. 2.2: Die verschiedenen Komponenten, die zur Lösung der Aufgabe „Hochkant stehendes Dreieck” benötigt werden

Versuchen wir doch mal, die Zahlen in Spalte (d) der Abbildung 2.2 in einem anderen Licht zu betrachten. Wenn wir, wie in Spalte (e) der Abbildung 2.2 gezeigt, nicht die Doppelkreuze, sondern die Leerzeichen zählen, dann ist Spalte (d) die korrekte Zahlenfolge! Stellen Sie sich vor, dass jede Zeile aus vier Kästchen besteht. Manche davon enthalten ein Doppelkreuz, andere sind leer. Ziehen Sie von diesen vier Kästchen einfach die Anzahl der leeren Kästchen ab, um die Anzahl der Doppelkreuze zu erhalten. Wenn jede Zeile vier Kästchen enthält, von denen abs(4 - row) leer sind, dann ist die Anzahl der Kästchen mit Doppelkreuz 4 - abs(4 - row). Das funktioniert. Setzen Sie diesen Ausdruck im Code ein und probieren Sie es selbst aus.

Bei dieser Aufgabenstellung haben wir durch Ausnutzen von Analogien und etwas Herumexperimentieren einen Großteil der Arbeit vermieden. Die Kombination dieser beiden Methoden ist ein ausgezeichneter Ansatz, wenn eine neue Aufgabenstellung einer anderen, die Sie bereits gelöst haben, stark ähnelt.

2.3 Eingabeverarbeitung

Bislang haben die Programme nur Ausgaben erzeugt. Das wollen wir nun ändern und Programme testen, die vornehmlich Eingaben verarbeiten. Allen diesen Programmen ist eine Randbedingung gemeinsam: Die Eingabe wird Zeichen für Zeichen eingelesen und ein Programm muss jedes Zeichen sofort verarbeiten, bevor das nächste Zeichen eingelesen wird. Mit anderen Worten: Das Programm speichert die Zeichen nicht in einer Datenstruktur, um diese später zu verwenden, sondern muss die Eingabe unmittelbar verarbeiten.

Bei der ersten dieser Aufgabenstellungen werden wir die Gültigkeit einer Identifikationsnummer (oder kurz ID-Nummer) überprüfen. In der Welt von heute bekommt fast alles eine ID-Nummer, wie beispielsweise Bücher eine ISBN oder ein Besteller im Versandhandel eine Kundennummer. Manchmal müssen diese Nummern von Hand eingegeben werden, was ein gewisses Fehlerpotenzial mit sich bringt. Falls eine fehlerhaft eingegebene Nummer mit keiner gültigen ID-Nummer übereinstimmt, kann das System diese problemlos zurückweisen. Aber was geschieht, wenn die Nummer zwar nicht stimmt, aber dennoch gültig ist? Wenn beispielsweise ein Kassierer beim Versuch, Ihrem Konto einen Betrag für eine Warenrückgabe gutzuschreiben, die Kontonummer eines anderen Kunden eingibt, dann erhält dieser andere Kunde Ihre Gutschrift. Damit so etwas nicht passieren kann, wurden Systeme entwickelt, um Fehler in ID-Nummern festzustellen. Dabei dient die eigentliche ID-Nummer als Eingabewert einer Formel, die daraus eine (ein- oder mehrstellige) Prüfziffer generiert, die dann zum Bestandteil einer erweiterten ID-Nummer wird. Wenn irgendeine der Ziffern geändert wird, stimmt die aus der ursprünglichen ID-Nummer generierte Prüfziffer nicht mit der errechneten Prüfziffer überein, und die Nummer kann zurückgewiesen werden.

Aufgabe: Test einer Luhn-Prüfsumme

Der Luhn-Algorithmus ist eine weit verbreitete Methode zur Überprüfung von ID-Nummern. Dabei wird jede zweite Ziffer der ursprünglichen ID-Nummer verdoppelt und die Summe der einzelnen Ziffern gebildet (falls einer der Werte nach dem Verdoppeln zweistellig ist, werden die beiden Ziffern einzeln addiert). Die ID-Nummer ist gültig, wenn die Summe durch 10 teilbar ist.

Schreiben Sie ein Programm, das eine ID-Nummer beliebiger Länge als Eingabe akzeptiert und die Gültigkeit der Nummer gemäß der Luhn-Formel überprüft. Das Programm muss jedes eingegebene Zeichen verarbeiten, bevor das nächste Zeichen eingelesen wird.

Abb. 2.3: Der Luhn-Algorithmus

Das Verfahren hört sich komplizierter an, als es ist, aber ein Beispiel wird das Ganze verdeutlichen. Unser Programm soll nur die Gültigkeit einer ID-Nummer prüfen, nicht aber neue Prüfziffern erzeugen. Wir wollen dennoch beide Seiten des Verfahrens durchgehen: die Berechnung einer Prüfziffer und die Überprüfung des Resultats. Das genaue Vorgehen ist in Abbildung 2.3 dargestellt. Im oberen Teil (a) wird die Prüfziffer errechnet. Die ursprüngliche ID-Nummer 176248 ist von einer gestrichelten Linie umrahmt. Ausgehend von der Ziffer ganz rechts (die nach dem Hinzufügen der Prüfziffer die zweite Ziffer von rechts ist), wird jede zweite Ziffer verdoppelt. Dann werden die einzelnen Werte addiert. Falls einer davon nach dem Verdoppeln zweistellig ist, werden beide Ziffern addiert. Wenn beispielsweise 7 verdoppelt wird, ergibt dies 14. Nun wird aber nicht 14 addiert, sondern die einzelnen Ziffern 1 und 4. In unserem Beispiel ergibt sich als Summe somit 27. Die Prüfsumme insgesamt soll durch 10 teilbar sein und muss daher mit einer 0 enden. Die nächsthöhere durch 10 teilbare Zahl ist 30, also ergibt sich als Prüfziffer die 3, denn 27 + 3 = 30.

Im unteren Teil (b) wird die ID-Nummer 1762483 überprüft, die am Ende die Prüfziffer 3 besitzt. Dieses Verfahren werden wir für die obige Aufgabenstellung verwenden. Wie zuvor verdoppeln wir, ausgehend von der Ziffer links der Prüfziffer, jede zweite Ziffer und addieren die Werte, die Prüfziffer eingeschlossen. Die Prüfsumme 30 ist durch 10 teilbar und die Gültigkeit der ID-Nummer ist bestätigt.

2.3.1 Zerlegung der Aufgabenstellung

Das Programm zur Lösung dieser Aufgabenstellung muss verschiedene Teilaufgaben erledigen, um die wir uns kümmern müssen. Eine davon ist das Verdoppeln der Ziffern, was nicht ganz einfach ist, weil die zu verdoppelnden Ziffern von der rechten Seite der ID-Nummer ausgehend festgelegt werden. Wie Sie wissen, dürfen wir nicht einfach alle Ziffern einlesen und speichern, um diese erst dann zu verarbeiten. Wir müssen die einzelnen Ziffern sogleich verarbeiten. Das Problem ist also, dass wir die Ziffern in der Reihenfolge von links nach rechts erhalten, obwohl wir diese eigentlich in der Reihenfolge von rechts nach links benötigen, um festzustellen, welche Ziffern verdoppelt werden müssen. Würden wir die Länge der ID-Nummer kennen, wüssten wir auch, welche Ziffern zu verdoppeln wären. Das ist aber nicht der Fall, denn in der Aufgabenstellung ist ausdrücklich von einer ID-Nummer beliebiger Länge die Rede. Eine weitere Schwierigkeit sind verdoppelte Werte >= 10, denn hier sind bei der Summierung beide Ziffern einzeln zu berücksichtigen. Außerdem müssen wir feststellen, wann die Eingabe der vollständigen ID-Nummer abgeschlossen ist. Und schließlich müssen wir herausfinden, wie wir die Nummer Ziffer für Ziffer einlesen können. Oder anders ausgedrückt: Der Benutzer gibt eine einzige lange Zahl ein, die wir aber so einlesen möchten, als ob die Ziffern einzeln eingegeben würden.

Da wir stets über einen Plan verfügen wollen, erstellen wir eine Liste der vor uns liegenden Aufgaben und nehmen diese eine nach der anderen in Angriff:

In Erfahrung bringen, welche Ziffern zu verdoppeln sind

Bei verdoppelten Werten >= 10 beide Ziffern einzeln berücksichtigen

Feststellen, wann das Ende der Nummer erreicht ist

Getrenntes Einlesen der einzelnen Ziffern

Beim Lösen von Aufgaben werden wir uns zunächst mit den einzelnen Teilaufgaben beschäftigen, bevor wir eine endgültige Lösung programmieren. Es ist daher nicht erforderlich, die Probleme in einer bestimmten Reihenfolge anzugehen. Fangen Sie mit der Aufgabe an, die Ihnen am einfachsten erscheint, oder derjenigen, die am schwierigsten aussieht, falls Sie eine Herausforderung suchen. Oder wählen Sie schlicht und einfach die interessanteste Aufgabe aus.

Lassen Sie uns zunächst die verdoppelten Werte >= 10 in Angriff nehmen. Hier liegt eine Situation vor, bei der die Randbedingungen die Sache eher vereinfachen, als sie zu erschweren. Die Berechnung der Summe der Ziffern einer beliebigen Ganzzahl ist an sich schon ein schönes Stück Arbeit, aber wie groß ist der mögliche Wertebereich? Wenn wir von den einzelnen Ziffern 0 bis 9 ausgehen und diese verdoppeln, ergibt sich als maximaler Wert 18. Es gibt daher nur zwei Möglichkeiten. Ist der verdoppelte Wert einstellig, gibt es nichts weiter zu tun. Ist der verdoppelte Wert >= 10, muss er zwischen 10 und 18 liegen, die erste Ziffer ist also immer eine 1. Diesen Ansatz wollen wir mit ein wenig Code in einem kleinen Experiment bestätigen:

int digit;
cout << "Einstellige Zahl von 0-9 eingeben: ";
cin >> digit;
int doubledDigit = digit * 2;
int sum;
if (doubledDigit >= 10) sum = 1 + doubledDigit % 10;
else sum = doubledDigit;
cout << "Summe für verdoppelten Wert: " << sum << "\n";

Hinweis

Das % kennzeichnet den Modulo-Operator. Bei positiven Ganzzahlen liefert er den Rest einer ganzzahligen Division. So ergibt 12 % 10 beispielsweise 2, denn wenn 12 durch 10 geteilt wird, verbleibt ein Rest von 2.

Der Code ist schnörkellos: Das Programm liest den Wert ein, verdoppelt ihn , summiert die Ziffern des verdoppelten Wertes und gibt die Summe aus . Der entscheidende Punkt ist hier die Summe für verdoppelte Werte, die >= 10 sind . Wie schon bei der Berechnung der Anzahl der in einer bestimmten Zeile erforderlichen Doppelkreuze bei den Aufgaben zur Mustererzeugung wird das Experimentieren sehr vereinfacht, wenn man die Berechnung in einem eigenen kurzen Programm isoliert betrachtet. Selbst wenn wir die korrekte Formel nicht auf Anhieb finden, werden wir sie doch schnell entdecken.

Bevor wir diesen Punkt auf unserer Liste abhaken, wollen wir aus diesem Code eine kleine Funktion machen, um dadurch die nachfolgenden Listings zu vereinfachen:

int doubleDigitValue(int digit) {
   int doubleDigit = digit * 2;
   int sum;
   if (doubledDigit >= 10) sum = 1 + doubledDigit % 10;
   else sum = doubledDigit;
   return sum;
}

Nun wollen wir uns um das Einlesen der einzelnen Ziffern der ID-Nummer kümmern. Wie zuvor könnten wir uns an dieser Stelle auch mit einer der anderen Aufgaben beschäftigen, dies ist jedoch eine gute Wahl, weil es uns dadurch möglich sein wird, die ID-Nummer bei der Untersuchung der anderen Teilaufgaben auf normale Art und Weise einzutippen.

Wenn wir die ID-Nummer als numerischen Datentyp (z. B. als int) einlesen, erhalten wir lediglich eine lange Zahl und hätten eine Menge Arbeit zu erledigen. Außerdem ist die Länge einer Ganzzahl begrenzt und laut Aufgabenstellung darf die ID-Nummer beliebig lang sein. Daher müssen wir sie Zeichen für Zeichen einlesen. Das bedeutet, dass wir herausfinden müssen, wie wir die durch Zeichen repräsentierten Ziffern einlesen und in Ganzzahlwerte umwandeln können, mit denen sich rechnen lässt. Werfen Sie einen Blick auf das nachstehende Listing, das eine Testausgabe liefert, um festzustellen, was geschieht, wenn die Zeichenwerte unmittelbar in einem numerischen Ausdruck verwendet werden.

char digit;
cout << "Einstellige Zahl eingeben: ";
digit = cin.get();
int sum = digit;
cout << "Ist die Summe der Ziffern " << sum << "? \n";

Einstellige Zahl eingeben: 7
Ist die Summe der Ziffern 55?

Beachten Sie, dass wir die Methode get verwenden, da der einfache Eingabe-Operator (wie z. B. cin >> digit) Leerraum überspringt. Das ist hier zwar ohne Belang, würde aber später Ärger verursachen, wie Sie noch sehen werden. Bei der Testeingabe und der dazugehörigen Ausgabe wird das Problem deutlich. Alle Computerdaten sind letzten Endes Zahlen und einzelne Zeichen werden durch einen ganzzahligen Zeichencode repräsentiert. Verschiedene Betriebssysteme verwenden zum Teil auch unterschiedliche Zeichencodesysteme, aber hier beschränken wir uns auf ASCII. In diesem System wird das Zeichen 7 als Zeichencode 55 gespeichert. Wenn wir das Zeichen als Ganzzahl behandeln, erhalten wir daher den Wert 55. Wir benötigen eine Methode, um das Zeichen 7 in die Ganzzahl 7 umzuwandeln.

Aufgabe: Ziffer in Ganzzahl konvertieren

Schreiben Sie ein Programm, das Benutzereingaben entgegennimmt, die eine der Ziffern von 0 bis 9 repräsentieren. Konvertieren Sie das Zeichen in die entsprechende Ganzzahl im Wertebereich von 0 bis 9 und geben Sie das Ergebnis aus.

Bei den Aufgaben zur Mustererzeugung im vorhergehenden Abschnitt ging es darum, den Wert einer Variablen aus einem bestimmten Wertebereich in einen Wert aus einem anderen Wertebereich umzuwandeln. Dazu haben wir eine Tabelle mit Spalten für die ursprünglichen und die erwünschten Werte erstellt sowie die Differenz ermittelt. Hier handelt es sich um ein analoges Problem, also behalten wir diese Idee bei (siehe Tabelle 2.2).

Zeichen | Zeichencode | Erwünschte Ganzzahl | Differenz
0 | 48 | 0 | 48
1 | 49 | 1 | 48
2 | 50 | 2 | 48
3 | 51 | 3 | 48
4 | 52 | 4 | 48
5 | 53 | 5 | 48
6 | 54 | 6 | 48
7 | 55 | 7 | 48
8 | 56 | 8 | 48
9 | 57 | 9 | 48

Tabelle 2.2: Zeichencodes und die erwünschten Ganzzahlen

Die Differenz zwischen Zeichencode und erwünschter Ganzzahl ist in allen Fällen 48, also brauchen wir einfach nur diesen Wert zu subtrahieren. Vielleicht ist Ihnen aufgefallen, dass es sich dabei um den Zeichencode für das Zeichen „0” handelt. Das stimmt immer, denn Zeichencodesysteme speichern die Ziffern, angefangen bei der 0, stets in aufsteigender Reihenfolge. Daher können wir eine etwas allgemeinere Lösung finden und anstelle eines vordefinierten Wertes, wie 48, das Zeichen 0 subtrahieren:

char digit;
cout << "Einstellige Zahl eingeben: ";
cin >> digit
int sum = digit – '0';
cout << "Ist die Summe der Ziffern " << sum << "? \n";

Nun können wir damit fortfahren, herauszufinden, welche der Ziffern zu verdoppeln sind. Dieser Teil der Aufgabe erfordert womöglich mehrere einzelne Schritte, lassen Sie es uns also mit einer Reduktion versuchen. Was wäre, wenn wir uns auf eine Nummer fester Längen beschränken würden? Dadurch würde unser Verständnis der allgemeinen Luhn-Formel gefestigt, während wir gleichzeitig Fortschritte auf dem Weg zum eigentlichen Ziel machen. Probieren wir einfach mal aus, die Länge auf sechs Ziffern zu begrenzen. Das ist hinreichend lang, um die Aufgabenstellung insgesamt gut wiederzugeben.

Aufgabe: Test einer Luhn-Prüfsumme fester Länge

Schreiben Sie ein Programm, das eine ID-Nummer mit einer Länge von sechs Zeichen (inklusive der Prüfziffer) als Eingabe akzeptiert und überprüft, ob die Nummer gemäß der Luhn-Formel gültig ist. Das Programm muss jedes eingegebene Zeichen verarbeiten, bevor das nächste Zeichen eingelesen wird.

Wie schon früher, können wir eine weitere Reduktion vornehmen, um den Einstieg möglichst einfach zu gestalten. Wenn wir die Formel so vereinfachen, dass keine der Ziffern verdoppelt wird, muss das Programm die Ziffern nur noch einlesen und aufsummieren.

Aufgabe: Test einer einfachen Prüfsumme fester Länge

Schreiben Sie ein Programm, das eine ID-Nummer mit einer Länge von sechs Zeichen (inklusive der Prüfziffer) als Eingabe akzeptiert und überprüft, ob die Nummer gemäß einer vereinfachten Formel gültig ist, bei der geprüft wird, ob die Summe der Werte aller Ziffern durch 10 teilbar ist. Das Programm muss jedes eingegebene Zeichen verarbeiten, bevor das nächste Zeichen eingelesen wird.

Da wir bereits wissen, wie eine einzelne Ziffer als Zeichen eingelesen wird, können wir diese Aufgabe ziemlich leicht lösen. Wir müssen nur sechs Ziffern einlesen, diese aufsummieren und prüfen, ob die Summe durch 10 teilbar ist.

char digit;
int checksum = 0;
cout << "Sechsstellige Zahl eingeben: ";
for (int position = 1; position <= 6; position++) {
   cin >> digit
   checksum += digit – '0';
}
cout << "Prüfsumme ist " << checksum ". \n"
if (checksum % 10 == 0) {
   cout << "Prüfsumme durch 10 teilbar. Gültig. \n";
else {
   cout << "Prüfsumme nicht durch 10 teilbar. Ungültig. \n";
}

Nun müssen wir die Logik der Luhn-Formel hinzufügen, also ausgehend von der sechsten Ziffer jede zweite verdoppeln. Da wir uns gegenwärtig auf sechsstellige Nummern beschränken, müssen daher (von der linken Seite aus gezählt) die Ziffern auf den Positionen 1, 3 und 5 verdoppelt werden. Oder anders ausgedrückt: Wir verdoppeln den Wert, wenn die Position eine ungerade Zahl ist. Ob eine Position ungerade bzw. gerade ist, können wir mittels des Modulo-Operators (%) überprüfen, denn eine gerade Zahl ist dadurch definiert, dass sie glatt durch 2 teilbar ist. Besitzt also der Ausdruck position % 2 den Wert 1, ist position ungerade und der Wert ist zu verdoppeln. Dabei ist es wichtig, daran zu denken, dass mit Verdoppeln hier sowohl das Verdoppeln der einzelnen Ziffer als auch das Summieren der beiden Ziffern gemeint ist, sofern der verdoppelte Wert >= 10 ist. Hier ist die vorhin erstellte Funktion doubleDigitValue wirklich nützlich. Wenn wir gemäß der Luhn-Formel eine Ziffer verdoppeln müssen, brauchen wir diese nur unserer Funktion zu übergeben und können deren Resultat verwenden. Um dies zu erreichen, müssen Sie lediglich den Code innerhalb der for-Schleife des vorhergehenden Listings wie folgt ändern:

for (int position = 1; position <= 6; position++) {
   cin >> digit
   if (position % 2 == 0) checksum += digit – '0';
   else checksum += doubleDigitValue(digit - '0');
}

Wir haben bisher schon eine Menge erreicht, aber es sind doch noch einige Schritte erforderlich, bevor wir den Code für ID-Nummern beliebiger Länge schreiben können. Um die Aufgabe endgültig lösen zu können, müssen wir teilen und herrschen (mit diesem Begriff werden in der Informatik reduktionistische Lösungsansätze bezeichnet)! Den vorhandenen Code so abzuändern, dass er mit 10- oder 16-stelligen ID-Nummern umgehen kann, wäre trivial. Sie müssten nur die Zahl 6, die als Obergrenze der Schleife dient, durch einen anderen Wert ersetzen. Wenn ich Sie nun aber bitte, auch 7-stellige Nummern auf ihre Gültigkeit zu überprüfen, ist eine weitere kleine Änderung erforderlich. Wenn nämlich die Anzahl der Ziffern ungerade ist und wir angefangen bei der zweiten Ziffer von rechts jede zweite Ziffer verdoppeln, wird die erste Ziffer auf der linken Seite nicht mehr verdoppelt. In diesem Fall müssen Sie die geraden Positionen verdoppeln: 2, 4, 6 usw. Stellen wir dieses Problem einen Moment zurück und sehen uns an, wie mit Nummern beliebiger Länge zu verfahren ist.

Als Erstes müssen wir feststellen, wann wir das Ende der Nummer erreicht haben. Wenn der Benutzer eine mehrstellige Nummer eingibt, (Enter) drückt und wir die Eingabe Zeichen für Zeichen einlesen, welches Zeichen folgt dann der letzten Ziffer? Das hängt tatsächlich vom verwendeten Betriebssystem ab. Daher machen wir wieder ein Experiment:

cout << "Zahl eingeben: ";
char digit;
while (true) {
   digit = cin.get();
   cout << int(digit) << sum << " ";
}

Diese Schleife bricht zwar nie ab, doch sie erledigt ihre Aufgabe. Wenn ich 1234 eintippe und (Enter) drücke, wird mir 49 50 51 52 10 angezeigt (vorausgesetzt man verwendet ASCII; das hängt vom Betriebssystem ab). Der gesuchte Zeichencode ist also 10. Dank dieser Information können wir die for-Schleife im vorhergehenden Codeabschnitt durch eine while-Schleife ersetzen:

char digit;
int checksum = 0;
int position = 1;
cout << "Zahl mit gerader Stellenzahl eingeben: ";

digit = cin.get();
while (digit != 10) {
   if (position % 2 == 0) checksum += digit – '0';
   else checksum += doubleDigitValue(digit – '0');
   digit = cin.get();
   position++;
}
cout << "Prüfsumme ist " << checksum ". \n"
if (checksum % 10 == 0) {
   cout << "Prüfsumme durch 10 teilbar. Gültig. \n";
else {
   cout << "Prüfsumme nicht durch 10 teilbar. Ungültig. \n";
}

In diesem Codeabschnitt ist position keine Schleifenvariable mehr und muss daher separat initialisiert und erhöht werden. Die Schleife wird nun durch die Bedingung gesteuert, die prüft, ob es sich beim Zeichencode um das Zeilenende handelt. Da wir einen Zeichencode benötigen, wenn die Schleife erstmals durchlaufen wird, lesen wir vor dem Start der Schleife ein Zeichen ein und lesen die nachfolgenden Werte innerhalb der Schleife , nachdem das Zeichen verarbeitet wurde.

Dieser Code kann also mit Nummern beliebiger, gerader Länge umgehen. Um auch mit Nummern beliebiger, ungerader Länge umgehen zu können, muss nur der Code zur Verarbeitung so modifiziert werden, dass die Bedingung in der if-Anweisung umgekehrt wird, damit die Ziffern an geraden Positionen statt an ungeraden verdoppelt werden.

Nun sind alle Möglichkeiten abgedeckt, denn die Länge der ID-Nummer muss ja entweder ungerade oder gerade sein. Wenn wir die Länge im Vorhinein kennen würden, wüssten wir, ob die ungeraden oder die geraden Positionen zu verdoppeln wären. Allerdings verfügen wir nicht über diese Information, solange das Ende der Nummer noch nicht erreicht ist. Ist mit diesen Randbedingungen eine Lösung möglich? Wie lässt sich diese Aufgabe lösen, wenn wir zwar wissen, wie mit einer ungeraden bzw. geraden Stellenanzahl zu verfahren ist, aber nicht wissen, wie viele Stellen die Nummer überhaupt besitzt, bevor sie nicht vollständig eingelesen ist?

Möglicherweise können Sie die Lösung schon erkennen. Falls nicht, liegt das nicht daran, dass sie besonders schwierig ist, sondern daran, dass sie sich hinter den ganzen Details verbirgt. Wir könnten nun eine Analogie gut brauchen, haben bislang aber keine erkennen können. Stattdessen entwickeln wir selbst eine Analogie. Wir denken uns eine Aufgabenstellung aus, die ausdrücklich eine vergleichbare Situation beschreibt, und werden dann schon sehen, ob es uns dabei hilft, eine Lösung zu finden, wenn wir uns dieser Aufgabe stellen. Befreien Sie Ihre Gedanken von einer vorgefassten Meinung, die Sie möglicherweise aufgrund der bislang verfolgten Ansätze haben, und lesen Sie die folgende Aufgabe.

Aufgabe: Positiv oder negativ

Schreiben Sie ein Programm, das 10 Ganzzahlen als Benutzereingabe entgegennimmt. Nach Eingabe aller Zahlen hat der Benutzer die Wahl, sich entweder die Anzahl der positiven oder aber die Anzahl der negativen Zahlen anzeigen zu lassen.

Hierbei handelt es sich um eine einfache Aufgabe, die offenbar keine weiteren Komplikationen mit sich bringt. Wir benötigen lediglich zwei Variablen zum Zählen der positiven bzw. negativen Zahlen. Wenn der Benutzer nach Ablauf des Programms die Wahl trifft, müssen wir nur die entsprechende Variable abfragen und diese anzeigen:

int number;
int positiveCount = 0;
int negativeCount = 0;
for (int i = 1; i <= 10; i++) {
   cin >> number
   if (number > 0) positiveCount++;
   if (numver < 0) negativeCount++;
}
char response;
cout << "Zahl der (p)ositiven oder (n)egativen anzeigen?";
cin >> response;
if (response == 'p')
   cout << "Positive Zahlen: " << positiveCount << "\n";
if (response == 'n')
   cout << "Negative Zahlen: " << negativeCount << "\n";

Diese Methode müssen wir auch bei der Aufgabenstellung zur Luhn-Prüfsumme verwenden. Führen Sie so Buch über die Prüfsumme, als ob die Länge der ID-Nummer sowohl ungerade als auch gerade wäre. Wenn wir ans Ende der ID-Nummer gelangen und deren tatsächliche Länge kennen, enthält eine der beiden Variablen die korrekte Prüfsumme.

2.3.2 Zusammenstellen der Teillösungen

Damit sind alle Punkte auf unserer ursprünglichen Liste abgehakt. Es ist nun an der Zeit, die Teillösungen zusammenzustellen und die Problemstellung insgesamt zu lösen. Da wir alle Teilaufgaben unabhängig voneinander gelöst haben, wissen wir genau, was zu tun ist, und können unsere bisherigen Programme als Referenz nutzen, um im Handumdrehen die endgültige Lösung zu erstellen:

char digit;
int oddLengthChecksum = 0;
int evenLengthChecksum = 0;
int position = 1;
cout << "Bitte eine Zahl eingeben: ";
digit = cin.get();
while (digit != 10) {
   if (position % 2 == 0) {
      oddLengthChecksum += doubleDigitValue(digit - '0');
      evenLengthChecksum += digit - '0';
   } else {
      oddLengthChecksum += digit - '0';
      evenLengthChecksum += doubleDigitValue(digit - '0');
   }
   digit = cin.get();
   position++;
}
int checksum;
if ((position-1) % 2 == 0) checksum = evenLengthChecksum;
else checksum = oddLengthChecksum;
cout << "Prüfsumme ist " << checksum << ". \n";
if (checksum % 10 == 0) {
   cout << "Prüfsumme durch 10 teilbar. Gültig. \n"; }
else {
   cout << "Prüfsumme nicht durch 10 teilbar. Ungültig. \n";
}

Beachten Sie, dass wir beim Test, ob die Länge der eingegebenen Nummer ungerade oder gerade ist , von position 1 subtrahieren. Das ist erforderlich, weil das letzte in der Schleife eingelesene Zeichen das Zeilenende ist, nicht das letzte Zeichen der Nummer. Wir hätten die Testbedingung stattdessen auch als (position % 2 == 1) schreiben können, allerdings stiftet das beim Lesen des Codes eher Verwirrung. Anders ausgedrückt: Es ist vernünftiger, „Verwende die gerade Prüfsumme, wenn position -1 gerade ist” als „Verwende die gerade Prüfsumme, wenn position ungerade ist” zu sagen und sich daran erinnern zu müssen, warum dies einen Sinn ergibt.

Dies ist der bislang längste Codeabschnitt, aber es ist nicht notwendig, dass ich den gesamten Code erneut kommentiere und die Arbeitsweise der einzelnen Teile beschreibe, da Sie diese bereits jeweils für sich allein betrachtet haben. Hier sieht man, welche Vorteile es bringt, einen Plan zur Verfügung zu haben. Es ist aber wichtig festzuhalten, dass mein Plan nicht notwendigerweise Ihrem Plan gleicht. Die Schwierigkeiten, die mir bei der ursprünglichen Beschreibung der Problemstellung aufgefallen sind, und die Schritte, die ich unternommen habe, um mit diesen Schwierigkeiten fertig zu werden, dürften sich wahrscheinlich deutlich von dem unterscheiden, was Ihnen ins Auge gefallen wäre bzw. was Sie in Angriff genommen hätten. Ihre Programmiererfahrung und die Problemstellungen, die Sie bereits erfolgreich lösen konnten, bestimmen, welche Teile einer Aufgabe trivial oder schwierig sind, und somit, welche Schritte Sie unternehmen müssen, um diese zu lösen. Möglicherweise gab es im vorhergehenden Abschnitt auch einen Punkt, an dem ich einen offenbar überflüssigen Umweg gegangen bin, um etwas herauszufinden, was Ihnen bereits völlig offensichtlich erschien. Umgekehrt bin ich vielleicht auch behände einen Punkt umgangen, der Ihnen vertrackt erschien. Hätten Sie all dies eigenständig durchgearbeitet, wären Sie möglicherweise zu einem genauso gut funktionierenden Programm gelangt, das sich deutlich von meinem unterscheidet. Es gibt keine „einzig wahre” Lösung einer Aufgabenstellung, denn alle Programme, die sämtliche Randbedingungen erfüllen, zählen als Lösung. Und es gibt auch keine „einzig wahre” Methode, eine Lösung zu erzielen.

Angesichts all der Schritte, die wir zum Aufspüren der Lösung unternommen haben, und dem doch ziemlich kompakten endgültigen Code könnten Sie in Versuchung geraten, bei Ihren eigenen Problemlösungsversuchen einige der Schritte wegzulassen. Davor kann ich nur warnen. Es ist immer die bessere Wahl, mehrere kleine Schritte durchzuführen, als zu versuchen, vieles auf einen Schlag zu erledigen, auch wenn manche Schritte trivial erscheinen. Rufen Sie sich die Ziele beim Problemlösen ins Gedächtnis. Vorrangiges Ziel ist es natürlich, ein Programm zu schreiben, das eine gegebene Problemstellung löst und alle Randbedingungen erfüllt. Ein zweites Ziel ist es, dieses Programm schnellstmöglich zu erstellen. Die Anzahl der Schritte zu minimieren, gehört nicht zu den Zielen, und schließlich muss niemand erfahren, wie viele Schritte Sie unternommen haben. Stellen Sie sich vor, dass Sie zum Gipfel eines steilen Bergs gelangen möchten, zu dem ein sanft ansteigender, aber langer und gewundener Weg führt. Diesen Weg links liegen zu lassen und den Gipfel stattdessen vom Fuß des Berges aus direkt zu erklimmen, erfordert sicherlich weniger Schritte, als den Weg zu nehmen, aber ist es auch schneller? Das wahrscheinlichste Resultat eines direkten Kletterversuchs ist wohl eher, dass Sie aufgeben müssen und scheitern.

Denken Sie außerdem auch an die letzte meiner allgemeinen Regeln für das Lösen von Problemen: Lassen Sie sich nicht entmutigen. Je mehr Arbeit Sie mit einem einzelnen Schritt zu erledigen versuchen, desto mehr laufen Sie Gefahr, frustriert zu werden. Auch wenn Sie sich bei einem schwierigen Schritt zurückhalten und diesen in mehrere einzelne Schritte unterteilen, ist das Kind bereits in den Brunnen gefallen, weil Sie das Gefühl haben werden, einen Rückschritt zu machen, statt vorwärtszukommen. Wenn ich angehende Programmierer dabei betreue, eine Aufgabe schrittweise zu lösen, bekomme ich gelegentlich zu hören: „Hey, der Schritt war zu einfach.” Darauf erwidere ich: „Und worüber wollen Sie sich beschweren?” Wenn es Ihnen gelingt, eine Problemstellung, die eingangs sehr schwer erscheint, in Teilaufgaben zu zerlegen, deren Lösungen nahezu trivial sind, kann ich nur sagen: „Glückwunsch!” Genau das sollten Sie sich erhoffen.

2.4 Statusverfolgung

Die letzte Problemstellung, die wir in diesem Kapitel durcharbeiten werden, ist auch die schwierigste. Die Aufgabe besteht aus vielen verschiedenen Teilen und die Beschreibung ist kompliziert, wodurch belegt wird, wie wichtig die Zerlegung komplexer Aufgabenstellungen in Teilaufgaben ist.

Aufgabe: Entschlüsseln einer Botschaft

Eine Botschaft wurde als Zeichenfolge verschlüsselt, die Zeichen für Zeichen eingelesen werden soll. Die Zeichenfolge enthält eine Reihe positiver Ganzzahlen, die durch Kommas voneinander getrennt sind und die sich in C++ als int-Variablen repräsentierten lassen. Allerdings hängt es vom aktuellen Entschlüsselungsmodus ab, welches Zeichen eine bestimmte Ganzzahl darstellt. Es gibt drei Modi: Großbuchstaben, Kleinbuchstaben und Satzzeichen.

Im Großbuchstabenmodus repräsentiert jede Ganzzahl einen Großbuchstaben: Die Ganzzahl modulo 27 gibt den Buchstaben des Alphabets an (wobei 1 = A, 2 = B usw.). Im Großbuchstabenmodus würde die Eingabe 143 daher den Buchstaben H liefern, denn 143 modulo 27 ergibt 8, und H ist der achte Buchstabe des Alphabets.

Der Kleinbuchstabenmodus arbeitet in gleicher Weise mit Kleinbuchstaben. Der Rest beim Teilen durch 27 stellt einen Kleinbuchstaben dar (1 = a, 2 = b usw.). Der Eingabewert 56 würde also im Kleinbuchstabenmodus den Buchstaben b liefern, denn 56 modulo 27 ergibt 2, und b ist der zweite Buchstabe des Alphabets.

Im Satzzeichenmodus bezeichnet Ganzzahl modulo 9 eines der in Tabelle 2.3 aufgeführten Zeichen. 19 würde beispielsweise ein Ausrufezeichen liefern, denn 19 modulo 9 ergibt 1.

Am Anfang einer Botschaft gilt immer der Entschlüsselungsmodus Großbuchstaben. Jedes Mal, wenn die Modulo-Operation (modulo 27 oder modulo 9, je nach Modus) 0 ergibt, wechselt der Entschlüsselungsmodus, und zwar vom Großbuchstabenmodus zum Kleinbuchstabenmodus, vom Kleinbuchstabenmodus zum Satzzeichenmodus und vom Satzzeichenmodus zurück zum Großbuchstabenmodus.

Nummer | Zeichen
1 | !
2 | ?
3 | ,
4 | .
5 | (Leerzeichen)
6 | ;
7 | "
8 | '

Tabelle 2.3: Satzzeichenmodus

Wie beim Luhn-Algorithmus werden wir ein konkretes Beispiel durcharbeiten, um sicherzustellen, dass alle erforderlichen Schritte in Ordnung sind. Abbildung 2.4 zeigt beispielhaft eine Entschlüsselung. Die ursprüngliche Zeichenfolge ist im oberen Bereich abgebildet. Die Verarbeitung erfolgt schrittweise von oben nach unten. Spalte (a) zeigt den aktuellen Wert, Spalte (b) den aktuellen Entschlüsselungsmodus, der von Großbuchstaben (G) über Kleinbuchstaben (K) zu Satzzeichen (S) wechselt. Die Spalte (c) gibt den Divisor des aktuellen Modus an. In Spalte (d) steht der Rest der Division des Divisors in Spalte (c) durch den aktuellen Eingabewert der Spalte (a). Das Ergebnis finden Sie in Spalte (e). Dabei handelt es sich entweder um ein Zeichen oder, falls das Resultat in Spalte (d) 0 ist, um den Wechsel zum nächsten Modus.

Abb. 2.4: Verarbeitung der Zeichenfolge beim Entschlüsseln einer Botschaft

Wie bei den vorhergehenden Aufgaben können wir damit anfangen, ausführlich die zur Lösung der Aufgabe erforderlichen Fähigkeiten zu untersuchen. Wir müssen eine Zeichenkette bis zum Erreichen des Zeilenendes einlesen, wobei die Zeichen Ganzzahlen repräsentieren. Wir müssen die Zeichen daher als Ziffern einlesen und in Ganzzahlen umwandeln, um sie weiterzuverarbeiten. Sobald die Ganzzahlen vorliegen, müssen wir sie in einzelne Zeichen konvertieren und ausgeben. Und schließlich müssen wir einen Weg finden, um den Entschlüsselungsmodus nachzuverfolgen, damit wir wissen, ob der aktuelle Eingabewert zu einem Kleinbuchstaben, einem Großbuchstaben oder zu einem Satzzeichen entschlüsselt werden soll. Lassen Sie uns aus alledem eine formale Liste erstellen:

Zeichen für Zeichen bis zum Zeilenende einlesen

Eine Zeichenfolge, die eine Nummer darstellt, in eine Ganzzahl konvertieren

Eine Ganzzahl von 1 bis 26 in einen Großbuchstaben konvertieren

Eine Ganzzahl von 1 bis 26 in einen Kleinbuchstaben konvertieren

Eine Ganzzahl von 1 bis 8 gemäß Tabelle 2.3 in ein Satzzeichen konvertieren

Statusverfolgung des Entschlüsselungsmodus

Wie sich der erste Punkt erledigen lässt, ist uns von der vorhergehenden Aufgabe bereits bekannt. Und auch wenn wir es beim Luhn-Algorithmus nur mit einzelnen Ziffern zu tun hatten, vermute ich, dass Teile des dort Gelernten sich auch für den zweiten Punkt der Liste als nützlich erweisen. Wahrscheinlich haben Sie den endgültigen Code der Luhn-Formel noch deutlich vor Augen, aber wenn Sie das Buch zwischenzeitlich zur Seite gelegt haben, möchten Sie vielleicht kurz nachlesen. Wenn Ihnen die Beschreibung einer Aufgabenstellung irgendwie bekannt vorkommt, sollten Sie generell ähnlichen Code zur Analyse aus Ihrem Archiv ausgraben.

Machen wir uns an die Arbeit und gehen die verbleibenden Aufgaben an. Vielleicht ist Ihnen aufgefallen, dass ich für jede Konvertierung einen eigenen Punkt erstellt habe. Ich erwarte, dass die Konvertierung einer Nummer in einen Kleinbuchstaben der Konvertierung einer Nummer in einen Großbuchstaben sehr ähnlich sein wird, die Konvertierung in ein Satzzeichen aber möglicherweise eine andere Herangehensweise erfordert. Jedenfalls bringt es keine Nachteile mit sich, die Liste zu kleinteilig zu gestalten. Es bedeutet lediglich, dass Sie öfter Gelegenheit haben, einen Punkt auf der Liste abzuhaken.

Tipp

Code zur späteren Wiederverwendung sichern

Die Ähnlichkeiten zwischen bestimmten Teilen der aktuellen und der vorhergehenden Aufgabenstellung machen deutlich, wie wichtig es ist, den Code in einer Form abzuspeichern, die den späteren Zugriff darauf erleichtert. Unter Softwareentwicklern ist die Wiederverwendung von Code häufiges Thema, die immer dann geschieht, wenn sie Teile vorhandener Software zum Erstellen neuer Software einsetzen. Oft sind solche Komponenten gekapselt oder Codeabschnitte werden wortwörtlich wiederverwendet. Es ist jedoch ebenso bedeutsam, über einen einfachen Zugang zu den früher programmierten Lösungen zu verfügen. Selbst wenn Sie den Code nicht vollständig kopieren, ermöglicht es Ihnen doch, auf die früher erworbenen Fähigkeiten und Techniken zurückzugreifen, ohne sie erneut erlernen zu müssen. Um einen möglichst großen Vorteil daraus zu ziehen, sollten Sie sich bemühen, sämtlichen Code, den Sie schreiben, sorgsam zu verwahren. (Selbstverständlich unter Berücksichtigung von Vereinbarungen hinsichtlich des geistigen Eigentums, die möglicherweise mit Arbeitgebern oder Kunden bestehen.)

Ob Sie tatsächlich in den Genuss sämtlicher Vorzüge kommen, die bereits geschriebene Programme bieten können, hängt in erster Linie davon ab, wie sorgfältig Sie beim Abspeichern vorgehen. Ist der Code unauffindbar, können Sie ihn auch nicht verwenden. Wenn Sie schrittweise vorgehen und einzelne Programme erstellen, um Ideen separat auszutesten, bevor Sie diese in die Gesamtlösung integrieren, sollten Sie dafür sorgen, dass der Code dieser Zwischenschritte ebenfalls gesichert wird. Sie werden später möglicherweise feststellen, dass es ungemein praktisch sein kann, diese Programme zur Verfügung zu haben, wenn die Übereinstimmungen zwischen der aktuellen Aufgabenstellung und der alten Software auf einem Themengebiet liegen, für das Sie bereits Testprogramme geschrieben haben.

Fangen wir mit der Konvertierung von Ganzzahlen in Zeichen an. Den Code zum Einlesen einzelner Ziffern und Konvertieren in eine Ganzzahl des Wertebereichs 0–9 kennen wir bereits vom Luhn-Algorithmus. Wie können wir diese Methode erweitern, damit sie auch mit mehrstelligen Zahlen umgehen kann? Betrachten wir den einfachsten Fall, zweistellige Zahlen. Die Aufgabe erscheint unkompliziert. Bei einer zweistelligen Zahl gibt die erste Ziffer die Anzahl der Zehnen an. Wir müssen also mit 10 multiplizieren und den Wert der zweiten Ziffer hinzuaddieren. Bei der Zahl 35 beispielsweise sind die eingelesenen Zeichen 3 und 5, die nach der Konvertierung zu Ganzzahlen durch den Ausdruck 3 * 10 + 5 das endgültige Ergebnis liefern. Das wollen wir mit etwas Code überprüfen:

cout << "Bitte zwei Ziffern eingeben: ";
char digitChar1 = cin.get();
char digitChar2 = cin.get();
int digit1 = digitChar1 - '0';
int digit2 = digitChar2 - '0';
int overallNumber = digit1 * 10 + digit2;
cout << "Die Ziffern als Ganzzahl " << overallNumber << "\n";

Es funktioniert: Das Programm gibt dieselbe Zahl aus, die eingegeben wird. Wenn wir versuchen, diese Methode zu erweitern, stoßen wir jedoch auf ein Problem. Dieses Programm verwendet zwei verschiedene Variablen, um die beiden eingegebenen Zeichen zu speichern. Das bereitet hier zwar keine Probleme, aber wir wollen diese Methode sicherlich nicht zu einer allgemeinen Lösung erweitern. Wenn wir das täten, bräuchten wir ebenso viele Variablen wie die Nummer Ziffern besitzt. Das würde schnell zu einem ziemlichen Durcheinander führen und Änderungen wären schwierig, wenn der Wertebereich zulässiger Eingabenummern variiert. Wir benötigen eine allgemeinere Lösung für die Teilaufgabe, Zeichen in Ganzzahlen zu konvertieren. Der erste Schritt, eine solche allgemeine Lösung zu finden, ist eine Vereinfachung des Codes, der jetzt nur noch zwei Variablen enthält, und zwar eine vom Typ char und eine vom Typ int:

cout << "Bitte zwei Ziffern eingeben: ";
char digitChar = cin.get();
int overallNumber = (digitChar - '0') * 10;
digitChar = cin.get();
overallNumber += (digitChar - '0');
cout << "Die Ziffern als Ganzzahl " << overallNumber << "\n";

Wir erreichen dies, indem alle Berechnungen mit der ersten Ziffer durchgeführt werden, bevor die zweite Ziffer eingelesen wird. Nach Einlesen der ersten Ziffer konvertieren wir diese in eine Ganzzahl, multiplizieren mit 10 und speichern das Ergebnis . Dann lesen wir die zweite Ziffer ein und addieren deren Wert als Ganzzahl dem Gesamtwert hinzu . Diese Methode ist dem ursprünglichen Code äquivalent, verwendet jedoch nur zwei Variablen, nämlich eine für das zuletzt eingelesene Zeichen und eine für den Gesamtwert der Ganzzahl. Nachdem das erledigt ist, werden wir wahrscheinlich schon ein Muster entdecken, das uns zu einer allgemeinen Lösung für eine beliebige Anzahl von Ziffern führt.

Wenn wir das probieren, stehen wir erneut vor einem Problem. Bei der zweistelligen Zahl haben wir die linke Ziffer mit 10 multipliziert, da diese die Zehnerposition belegt. Bei einer dreistelligen Zahl belegt die Ziffer ganz links die Hunderterposition. Wir müssten mit 100 multiplizieren, dann die mittlere Ziffer einlesen, mit 10 multiplizieren, das Resultat zum Gesamtwert addieren und schließlich die letzte Ziffer einlesen und deren Wert ebenfalls hinzuaddieren. Das sollte funktionieren, bringt uns aber einer allgemeinen Lösung nicht näher. Erkennen Sie das Problem? Sehen Sie sich die obige Aussage noch mal näher an: Bei einer dreistelligen Zahl belegt die Ziffer ganz links die Hunderterposition. Bei einer allgemeinen Lösung wissen wir nicht, aus wie vielen Ziffern die Zahl besteht, solange nicht das nächste Komma erreicht ist. Die ganz links befindliche Ziffer einer Zahl unbekannter Länge darf nicht der Hunderterposition (oder irgendeiner anderen) zugeordnet werden. Wie können wir in Erfahrung bringen, welcher Multiplikator für die einzelnen Ziffern zu verwenden ist, bevor der Gesamtwert erhöht wird? Oder benötigen wir einen völlig anderen Ansatz?

Wenn man nicht weiterkommt, ist es immer erfolgversprechend, eine vereinfachte Form des Problems zu entwickeln und in Angriff zu nehmen. Die Schwierigkeit ist hier die Unkenntnis der Länge der Zahl. Bei der simpelsten Aufgabenstellung hierzu gäbe es nur zwei mögliche Werte für die Anzahl der Ziffern.

Aufgabe: Einlesen einer Zahl mit drei oder vier Ziffern

Schreiben Sie ein Programm, das eine Zahl zeichenweise einliest und in eine Ganzzahl konvertiert, dafür aber nur jeweils eine Variable vom Typ char bzw. int verwendet. Die Zahl besitzt entweder drei oder vier Ziffern.

Das Problem, dass die Länge der Zahl unbekannt ist, solange das Zeilenende nicht erreicht ist, eben diese Länge jedoch bereits beim Programmstart benötigt wird, entspricht demjenigen bei der Luhn-Formel. Beim Luhn-Algorithmus wussten wir nicht, ob die Länge der ID-Nummer eine ungerade oder eine gerade Zahl ist. Die Lösung bestand darin, die Prüfsumme auf zweierlei Weise zu berechnen und zum Schluss das geeignete Resultat auszuwählen. Könnten wir etwas Vergleichbares nicht auch hier probieren? Da die Zahl entweder drei oder vier Ziffern besitzt, gibt es nur zwei Möglichkeiten. Ist sie dreistellig, belegt die Ziffer ganz links die Hunderterposition, ist sie hingegen vierstellig, belegt sie die Tausenderposition. Wir müssten dann so rechnen, als ob die Zahl sowohl drei- als auch vierstellig wäre, und zum Schluss die richtige auswählen, aber die Randbedingungen erlauben nur eine einzige numerische Variable. Vergessen wir diese Vorschrift für einen Moment, um einen Fortschritt zu erzielen.

Aufgabe: Einlesen einer Zahl mit drei oder vier Ziffern, weiter vereinfacht

Schreiben Sie ein Programm, das eine Zahl zeichenweise einliest und in eine Ganzzahl konvertiert, dafür aber nur eine Variable vom Typ char und zwei vom Typ int verwendet. Die Zahl besitzt entweder drei oder vier Ziffern.

Nun können wir die Methode „Beide Ergebnisse berechnen” zum Einsatz bringen. Wir verarbeiten die ersten drei Ziffern auf zweierlei Weise und prüfen dann, ob es eine vierte Ziffer gibt:

cout << "Drei- oder vierstellige Zahl eingeben: ";
char digitChar = cin.get();
int threeDigitNumber = (digitChar - '0') * 100;
int fourDigitNumber = (digitChar - '0') * 1000;
digitChar = cin.get();
threeDigitNumber += (digitChar - '0') * 10;
fourDigitNumber += (digitChar - '0') * 100;
digitChar = cin.get();
threeDigitNumber += (digitChar - '0');
fourDigitNumber += (digitChar - '0') * 10;
digitChar = cin.get();
if (digitChar == 10) {
   cout << "Eingegebene Zahl: " << threeDigitNumber << "\n"; }
else {
   fourDigitNumber += (digitChar - '0');
   cout << "Eingegebene Zahl: " << fourDigitNumber << "\n";
}

Nach dem Einlesen der ersten Ziffer multiplizieren wir deren Wert mit 100 und speichern das Ergebnis in der Variablen für dreistellige Eingaben . Außerdem multiplizieren wir den Ganzzahlwert mit 1000 und legen das Resultat in der Variablen für vierstellige Eingaben ab . Bei den nächsten beiden Ziffern fahren wir nach dem gleichen Muster fort. Mit der zweiten Ziffer wird sowohl wie mit der Zehnerposition einer dreistelligen Zahl als auch wie mit der Hunderterposition einer vierstelligen Zahl verfahren. Die dritte Ziffer wird als Einer- bzw. Zehnerposition behandelt. Nach Einlesen des vierten Zeichens prüfen wir durch Vergleich mit der Zahl 10 , ob es sich um das Zeilenende handelt (dieser Wert ist, wie erwähnt, betriebssystemabhängig). Ist dies der Fall, handelt es sich bei der Eingabe um eine dreistellige Zahl. Anderenfalls muss zum Gesamtwert noch der Wert der Einerposition hinzuaddiert werden .

Nun müssen wir einen Weg finden, eine der beiden int-Variablen loszuwerden. Nehmen wir an, wir würden die Variable fourDigitNumber komplett entfernen. Der Wert von threeDigitNumber wäre nach wie vor korrekt, aber wenn wir zu dem Punkt gelangen, an dem fourDigitNumber benötigt wird, fehlt uns diese Variable. Kann man den Wert, den fourDigitNumber hätte, nicht irgendwie anhand des Werts von threeDigitNumber bestimmen? Nehmen wir an, die Eingabe lautet 1234. Nach Einlesen der ersten drei Ziffern wäre der Wert von threeDigitNumber 123 und der von fourDigitNumber 1230. Da der Multiplikator für fourDigitNumber immer das Zehnfache des Multiplikators für threeDigitNumber beträgt, ist fourDigitNumber stets 10 Mal so groß wie threeDigitNumbers. Daher wird nur eine int-Variable benötigt, denn die eine kann bei Bedarf durch Multiplikation mit 10 aus der anderen errechnet werden:

cout << "Drei- oder vierstellige Zahl eingeben: ";
char digitChar = cin.get();
int number = (digitChar - '0') * 100;
digitChar = cin.get();
number += (digitChar - '0') * 10;
digitChar = cin.get();
number += (digitChar - '0');
digitChar = cin.get();
if (digitChar == 10) {
   cout << "Eingegebene Zahl: " << number << "\n"; }
else {
   number = number * 10 + (digitChar - '0');
   cout << "Eingegebene Zahl: " << number << "\n";
}

Nun verfügen wir über ein verwertbares Muster. Überlegen Sie, wie sich der Code erweitern lässt, um fünfstellige Zahlen zu verarbeiten. Nach Berechnung des korrekten Wertes für die ersten vier Ziffern verfahren wir genauso wie vorher beim Einlesen der vierten Ziffer, anstatt das Resultat sofort auszugeben: Einlesen eines fünften Zeichens, Prüfen, ob es sich um das Zeilenende handelt, Anzeige des errechneten Ergebnisses, wenn dem so ist, oder anderenfalls Multiplikation mit 10 und Hinzuaddieren des Werts der aktuellen Ziffer:

cout << "Drei-, vier- oder fünfstellige Zahl eingeben: ";
char digitChar = cin.get();
int number = (digitChar - '0') * 100;
digitChar = cin.get();
number += (digitChar - '0') * 10;
digitChar = cin.get();
number += (digitChar - '0');
digitChar = cin.get();
if (digitChar == 10) {
   cout << "Eingegebene Zahl: " << number << "\n"; }
else {
number = number *10 + (digitChar - '0');
   digitChar = cin.get();
   if (digitChar == 10) {
cout << "Eingegebene Zahl: " << number << "\n";
   } else {
      number = number *10 + (digitChar - '0');
      cout << "Eingegebene Zahl: " << number << "\n";
   }
}

Jetzt könnten wir den Code leicht erweitern, um auch sechsstellige Zahlen oder Zahlen mit weniger Ziffern zu verarbeiten. Nach welchem Muster dies geschieht, ist nun klar: Handelt es sich beim nächsten Zeichen um eine weitere Ziffer, muss der bisherige Gesamtwert mit 10 multipliziert werden, bevor der Ganzzahlwert der Ziffer zum Gesamtwert hinzuaddiert wird. Mit diesem Wissen können wir eine Schleife programmieren, um Zahlen beliebiger Länge zu verarbeiten:

cout << "Beliebig lange Zahl eingeben: ";
char digitChar = cin.get();
int number = (digitChar - '0');
digitChar = cin.get();
while (digitChar != 10) {
   number = number * 10 + (digitChar - '0');
   digitChar = cin.get();
}
cout << "Eingegebene Zahl: " << number << "\n";

Hier lesen wir das erste Zeichen ein und bestimmen dessen Ganzzahlwert . Dann lesen wir das zweite Zeichen und erreichen die Schleife, deren Abbruchbedingung prüft, ob das zuletzt eingelesene Zeichen ein Zeilenende ist . Falls nicht, multiplizieren wir den Gesamtwert mit 10 und addieren den Ganzzahlwert des aktuellen Zeichens zum Gesamtwert hinzu , bevor wir das nächste Zeichen lesen . Wenn das Zeilenende erreicht ist, enthält die Variable number den Gesamtwert, der dann ausgegeben wird .

Damit ist die Konvertierung einer Zeichenfolge in ihr ganzzahliges Gegenstück erledigt. Im endgültigen Programm werden wir eine Reihe von Zahlen einlesen, die durch Kommas voneinander getrennt sind. Jede dieser Zahlen muss separat eingelesen und verarbeitet werden. Es ist wie immer am besten, zunächst Überlegungen zu einer möglichst einfachen Situation anzustellen, in der das Problem zutage tritt. Sehen wir uns die Zeichenfolge 101,22[EOL] einmal genauer an, in der [EOL] (End of Line) zur Verdeutlichung das Zeilenende ausdrücklich markiert. Es würde schon ausreichen, die Abbruchbedingung der Schleife so abzuändern, dass sie prüft, ob das aktuelle Zeichen ein Zeilenende oder ein Komma ist. Der komplette Code zur Verarbeitung einer Zahl muss sich dann innerhalb einer äußeren Schleife befinden, die so lange ausgeführt wird, bis alle Zahlen gelesen worden sind. Die innere Schleife muss also bei [EOL] oder einem Komma abbrechen, die äußere hingegen nur bei [EOL]:

char digitChar;
do {
    digitChar = cin.get();
    int number = (digitChar - '0');
    digitChar = cin.get();
    while ((digitChar != 10) && (digitChar != ',')) {
       number = number * 10 + (digitChar - '0');
       digitChar = cin.get();
    }
    cout << "Eingegebene Zahl: " << number << "\n";
} while (digitChar != 10);

Hier sehen Sie ein weiteres schönes Beispiel dafür, wie wichtig es ist, in kleinen Schritten vorzugehen. Auch wenn es sich hier um ein sehr kurzes Programm handelt, hätte die komplizierte Struktur der verschachtelten Schleife wohl zu ziemlich kniffligem Code geführt, wenn wir versucht hätten, das Ganze sofort zu programmieren. Dessen ungeachtet gelangen wir in geradliniger Weise zu diesem Code, wenn wir einen der Schritte des vorhergehenden Programms verwenden. Die Deklaration von digitChar wird in eine eigene Codezeile verschoben, damit der Gültigkeitsbereich der Variablen den gesamten Code umfasst. Der Rest des Codes entspricht dem des vorhergehenden Listings, befindet sich jedoch innerhalb einer do-while-Schleife, die so lange läuft, bis das Zeilenende erreicht ist .

Nachdem dieser Teil der Aufgabenerstellung erledigt ist, können wir uns der Verarbeitung der einzelnen Zahlen zuwenden. Der nächste Punkt auf unserer Liste ist die Konvertierung einer Zahl von 1 bis 26 in einen der Buchstaben von A bis Z. Wenn Sie darüber nachdenken, werden Sie feststellen, dass es sich eigentlich um eine Umkehrung der Konvertierung von Zeichen in Ganzzahlen handelt. Wenn wir durch die Subtraktion des Zeichencodes für 0 die Zeichencodes für 0–9 in die entsprechende Ganzzahl überführen können, sollte es auch möglich sein, durch Addition eines Zeichencodes die Zahlen von 1 bis 26 in die Zeichen A–Z zu übersetzen. Was geschieht, wenn wir 'A' addieren? Hier ein Versuch, nebst einer Testeingabe und deren Resultat:

cout << "Zahl von 1 bis 26 eingeben: ";
int number;
cin >> number;
char outputCharacter;
outputCharacter = number + 'A';
cout << "Entsprechendes Zeichen: " << outputCharacter << "\n";
Zahl von 1 bis 26 eingeben: 5 Entsprechendes Zeichen: F

Das ist noch nicht ganz korrekt. Der fünfte Buchstabe des Alphabets ist E, nicht F. Das Problem tritt auf, weil wir eine Zahl zu einem Wertebereich addieren, der mit 1 beginnt. Beim Konvertieren in der anderen Richtung, nämlich von einem Zeichencode in eine Ganzzahl, haben wir es mit einem Wertebereich zu tun, der mit 0 beginnt. Durch Ändern der Berechnung von number + 'A' auf number + 'A' - 1 lässt sich das Problem beheben. Beachten Sie, dass wir auch den Zeichencode für den Buchstaben A nachschlagen könnten (der ASCII-Wert ist 65), davon 1 abziehen und diesen Wert verwenden (im Fall von ASCII beispielsweise number + 64). Ich ziehe jedoch die erste Version vor, da sie besser verständlich ist. Soll heißen: Wenn Sie den Code später erneut verwenden, werden Sie sich eher an die Bedeutung von number + 'A' -1 als an diejenige von number + 64 erinnern, denn das Vorkommen von 'A' weist darauf hin, dass es um die Konvertierung in Großbuchstaben geht.

Nachdem das geklärt ist, können wir die gleiche Idee zur Konvertierung in Kleinbuchstaben verwenden, indem die Berechnung auf number + 'a' - 1 geändert wird. Die Konvertierung in Satzzeichen ist nicht ganz so kompakt, da die Satzzeichen weder im ASCII noch in irgendeinem anderen Zeichencodesystem in dieser Reihenfolge vorkommen. Wir kommen nicht umhin, die Aufgabe mit der Brechstange zu lösen:

cout << "Zahl von 1 bis 8 eingeben: ";
int number;
cin >> number;
char outputCharacter;
switch (number) {
   case 1: outputCharacter = '!'; break;
   case 2: outputCharacter = '?'; break;
   case 3: outputCharacter = ','; break;
   case 4: outputCharacter = '.'; break;
   case 5: outputCharacter = ' '; break;
   case 6: outputCharacter = ';'; break;
   case 7: outputCharacter = '"'; break;
   case 8: outputCharacter = '\''; break;
}
cout << "Entsprechendes Zeichen: " << outputCharacter << "\n";

Wir verwenden hier eine switch-Anweisung zur Ausgabe des entsprechenden Satzzeichens. Beachten Sie den Backslash „\” , der zur Quotierung des einfachen Anführungsstrichs erforderlich ist.

Es gibt eine letzte Teilaufgabe zu bewältigen, bevor wir aus den Teillösungen die endgültige Lösung zusammenstellen können, nämlich das Wechseln des Entschlüsselungsmodus, wenn der zuletzt eingelesene Wert entschlüsselt 0 ergibt. Wie Sie wissen, schreibt die Aufgabenstellung vor, jeweils den Teilungsrest bei Division durch 27 (im Groß- und Kleinbuchstabenmodus) bzw. 9 (im Satzzeichenmodus) zu berechnen. Ist das Ergebnis 0, wechseln wir in den nächsten Modus. Wir benötigen also eine Variable, die den aktuellen Modus speichert und eine Programmlogik innerhalb der Schleife zum Einlesen und Verarbeiten des nächsten Zeichens, die bei Bedarf den Modus ändert. Für die Variable zur Statusverfolgung könnten wir eine einfache Ganzzahl verwenden, es ist aber besser verständlich, wenn wir einen Aufzählungstyp (enum) benutzen. Eine Faustregel lautet: Wenn eine Variable ausschließlich der Statusverfolgung dient und die einzelnen Werte keine eigene Bedeutung besitzen, ist eine Enumeration angebracht. In diesem Fall könnten wir einer einfachen int-Variablen mode willkürlich die Werte 1, 2 und 3 mit der Bedeutung Großbuchstabenmodus, Kleinbuchstabenmodus und Satzzeichenmodus zuweisen, wobei es keinen speziellen Grund gibt, diese Werte zu verwenden. Wenn wir uns später erneut mit dem Code befassen, müssten wir uns allerdings erst wieder mit diesem System vertraut machen, um die Bedeutung einer Anweisung wie beispielsweise (mode == 2) zu verstehen. Verwenden wir hingegen einen Aufzählungstyp à la (mode == GROSSBUCHSTABEN), ist vollkommen klar, was gemeint ist. Nachstehend folgen der aus diesen Überlegungen resultierende Code sowie einige Testeingaben.

enum modeType {GROSSBUCHSTABEN, KLEINBUCHSTABEN, SATZZEICHEN};
int number;
modeType mode = GROSSBUCHSTABEN;
cout << "Bitte Zahlen eingeben, -1 zum Beenden: ";
do {
   cin >> number;
   cout << "Gelesene Zahl: " << number;
   switch (mode) {
      case GROSSBUCHSTABEN:
         number = number % 27;
         cout << ". Modulo 27: " << number << ". ";
         if (number == 0) {
            cout << "Wechsel zu KLEINBUCHSTABEN";
            mode = KLEINBUCHSTABEN;
         }
         break;
      case KLEINBUCHSTABEN:
         number = number % 27;
         cout << ". Modulo 27:" << number << ". ";
         if (number == 0) {
            cout << "Wechsel zu SATZZEICHEN";
            mode = SATZZEICHEN;
         }
         break;
      case SATZZEICHEN:
         number = number % 9;
         cout << ". Modulo 9: " << number << ". ";
         if (number == 0) {
            cout << "Wechsel zu GROSSBUCHSTABEN";
            mode = GROSSBUCHSTABEN;
         }
         break;
   }
   cout << "\n";
} while (number != -1);

Bitte Zahlen eingeben, -1 zum Beenden:
2 1 0 52 53 54 55 6 7 8 9 10 -1
Gelesene Zahl: 2. Modulo 27: 2.
Gelesene Zahl: 1. Modulo 27: 1.
Gelesene Zahl: 0. Modulo 27: 0. Wechsel zu KLEINBUCHSTABEN
Gelesene Zahl: 52. Modulo 27: 25.
Gelesene Zahl: 53. Modulo 27: 26.
Gelesene Zahl: 54. Modulo 27: 0. Wechsel zu SATZZEICHEN
Gelesene Zahl: 55. Modulo 9: 1.
Gelesene Zahl: 6. Modulo 9: 6.
Gelesene Zahl: 7. Modulo 9: 7.
Gelesene Zahl: 8. Modulo 9: 8.
Gelesene Zahl: 9. Modulo 9: 0. Wechsel zu GROSSBUCHSTABEN
Gelesene Zahl: 10. Modulo 27: 10.
Gelesene Zahl: -1. Modulo 27: -1.

Wir haben damit alle Punkte auf unserer Liste abgehakt und können uns nun damit beschäftigen, die einzelnen Codeabschnitte zu einem endgültigen Programm zu vereinigen. Diese Zusammenfassung können wir auf verschiedene Weise angehen. Wir könnten zwei Teillösungen zusammenlegen und uns dann weiter vorarbeiten. So könnten wir beispielsweise den Code zum Einlesen und Konvertieren der durch Kommas getrennten Zahlen mit dem Moduswechsel des letzten Codeabschnitts kombinieren. Den zusammengeführten Code könnten wir dann austesten und danach den Code zum Konvertieren der einzelnen Zahlen in Buchstaben bzw. Satzzeichen hinzufügen. Wir könnten die Aufgabe auch in umgekehrter Richtung angehen und den Codeabschnitt zum Konvertieren von Zahlen in Zeichen in eine Reihe von Funktionen umwandeln, die vom Hauptprogramm aus aufgerufen werden. Wir sind jetzt beinahe an einen Punkt gelangt, an dem es mehr um Softwareentwicklung als um Problemlösungen geht, und dabei handelt es sich um ein anderes Thema. Den schwierigen Teil, nämlich das Erstellen einer Reihe von Codeabschnitten, haben wir hinter uns und müssen diese nur noch, wie im nachstehenden Listing, zusammenbauen.

char outputCharacter;
enum modeType {GROSSBUCHSTABEN, KLEINBUCHSTABEN, SATZZEICHEN};
modeType mode = GROSSBUCHSTABEN;
char digitChar;
do {
   digitChar = cin.get();
   int number = (digitChar - '0');
   digitChar = cin.get();
   while ((digitChar != 10) && (digitChar != ',')) {
      number = number * 10 + (digitChar - '0');
      digitChar = cin.get();
   }
   switch (mode) {
      case GROSSBUCHSTABEN:
         number = number % 27;
         outputCharacter = number + 'A' – 1;
         if (number == 0) {
            mode = KLEINBUCHSTABEN;
            continue;
         }
         break;
      case KLEINBUCHSTABEN:
         number = number % 27;
         outputCharacter = number + 'a' - 1;
         if (number == 0) {
            mode = SATZZEICHEN;
            continue;
         }
         break;
      case SATZZEICHEN:
         number = number % 9;
         switch (number) {
            case 1: outputCharacter = '!'; break;
            case 2: outputCharacter = '?'; break;
            case 3: outputCharacter = ','; break;
            case 4: outputCharacter = '.'; break;
            case 5: outputCharacter = ' '; break;
            case 6: outputCharacter = ';'; break;
            case 7: outputCharacter = '"'; break;
            case 8: outputCharacter = '\''; break;
         }
         if (number == 0) {
            mode = GROSSBUCHSTABEN;
            continue;
         }
         break;
   }
    cout << outputCharacter;
} while (digitChar != 10);
cout << "\n";

Fast jede Zeile des Programms entstammt den in diesem Abschnitt entwickelten Codebeispielen. Der Hauptteil des Programms ist dem Codeabschnitt zum Moduswechsel entnommen. Die zentrale Verarbeitungsschleife geht auf den Code zum zeichenweisen Einlesen einer Reihe durch Kommas getrennter Zahlen zurück. Schließlich ist auch der Code erkennbar, der Ganzzahlen in Großbuchstaben, Kleinbuchstaben und Satzzeichen konvertiert . Der wenige neu hinzugekommene Code ist mit gekennzeichnet. Die continue-Anweisung setzt die Schleife unmittelbar mit dem nächsten Wert der Schleifenvariablen fort, wenn die letzte Eingabe zu einem Moduswechsel führt, und überspringt dadurch die Zeile cout << outputCharacter; am Ende der Schleife.

Es handelt sich hier zwar um reines Kopieren-und-Einsetzen, allerdings um sinnvolles Kopieren-und-Einsetzen, bei dem Sie den eigenhändig geschriebenen Code wiederverwenden, den Sie daher auch vollständig verstanden haben. Denken Sie wie beim letzten Mal darüber nach, wie einfach die einzelnen Schritte waren, und vergleichen Sie es mit dem Versuch, den endgültigen Code direkt so hinzubekommen. Ein guter Programmierer ist zweifelsohne in der Lage, den endgültigen Code ohne die Zwischenschritte zu erstellen, es gäbe aber ohne Frage Schritte in die falsche Richtung, Phasen mit unschönem Code und auskommentierte Codezeilen, die dann doch wieder Verwendung finden. Durch die kleineren Schritte wird die ganze „Drecksarbeit” schon früh erledigt und der Code wird nie allzu unansehnlich geraten, weil der Code, an dem wir arbeiten, zu keinem Zeitpunkt lang oder kompliziert wird.

2.5 Fazit

In diesem Kapitel haben wir uns drei verschiedene Problemstellungen angesehen. Wir mussten einerseits drei verschiedene Wege einschlagen, um diese zu lösen, andrerseits haben wir jedes Mal dieselbe Methode verwendet, da wir immer dieselbe grundlegende Vorgehensweise der Zerlegung des Problems in Komponenten eingesetzt haben. Wir haben Code geschrieben, der die Aufgaben dieser einzelnen Komponenten erledigt, und das dabei gewonnene Wissen (oder sogar direkt Teile des Codes) zur Lösung der ursprünglichen Problemstellung verwendet. In den folgenden Kapiteln werden wir die Methode nicht ausdrücklich bei jedem Problem zum Einsatz bringen, aber die fundamentale Idee bleibt bestehen, nämlich die Problemstellung in Teilaufgaben zu zerlegen, die sich bewältigen lassen.

Entsprechend Ihrer Programmiererfahrung mögen Ihnen die verschiedenen Aufgabenstellungen anfangs als irgendetwas zwischen teuflisch schwer und trivial erschienen sein. Ich empfehle die Anwendung dieser Methode bei jeder neuen Aufgabenstellung, der Sie sich gegenübersehen, egal, wie schwierig sie anfangs auch aussieht. Sie werden kaum darauf warten wollen, eine frustrierend schwere Aufgabe gestellt zu bekommen, bevor Sie eine andere Technik ausprobieren. Und denken Sie daran: Es gehört zu den Zielen dieses Buches, dass Sie die Zuversicht gewinnen, zur Lösung von Problemstellungen fähig zu sein. Üben Sie den Einsatz dieser Techniken an „leichten” Aufgabenstellungen und Sie werden genügend Schwung haben, wenn Sie auf die wirklich schwierigen treffen.

2.6 Übungen

Wie im letzten Kapitel möchte ich Sie dazu auffordern, so viele der Übungen wie möglich auszuprobieren. Da wir thematisch inzwischen voll und ganz bei der Programmierung angekommen sind, ist das Durcharbeiten der Übungen für die Weiterentwicklung Ihrer Problemlösungsfähigkeiten unerlässlich.

2.1

Schreiben Sie unter Beibehaltung der Randbedingungen, die für die bereits untersuchten Programme zur Mustererzeugung galten (nur zwei Ausgabeanweisungen; eine zur Ausgabe eines Doppelkreuzes und eine zur Ausgabe des Zeilenendes) sowie einer weiteren Randbedingung, die das Ausgeben einen Leerzeichens erlaubt, ein Programm, das folgendes Muster ausgibt:

########
 ######
  ####
   ##

2.2

Oder dieses:

   ##
  ####
 ######
########
########
 ######
  ####
   ##

2.3

Und hier ein besonders kniffliges:

#            #
 ##        ##
  ###    ###
   ########
   ########
  ###    ###
 ##        ##
#            #

2.4

Entwerfen Sie selbst ein Muster. Denken Sie sich ein symmetrisches Muster von Doppelkreuzen aus und schreiben Sie ein Programm, das dieses Muster unter Einhaltung der Randbedingungen ausgibt.

2.5

Versuchen Sie, ein Programm zu schreiben, das ein anderes Prüfziffernsystem (z. B. das 13-stellige ISBN-System) implementiert, wenn Ihnen die Aufgabenstellung beim Luhn-Algorithmus zugesagt hat. Das Programm könnte eine ISBN überprüfen oder eine Nummer ohne Prüfziffer entgegennehmen und diese errechnen.

2.6

Schreiben Sie Programme zur Konvertierung von Dezimalzahlen beliebiger Länge in Binärzahlen (und umgekehrt), wenn Ihnen Binärzahlen und deren Konvertierung geläufig sind. Sie dürfen dabei annehmen, dass die Zahlen klein genug sind, um sie in normalen C++-int-Variablen abzuspeichern.

2.7

Kennen Sie sich auch mit Hexadezimalzahlen aus? Versuchen Sie, ein Programm zu schreiben, das dem Benutzer die Eingabe einer hexadezimalen, dezimalen oder binären Zahl gestattet und diese in allen drei Zahlensystem wieder ausgibt.

2.8

Suchen Sie eine zusätzliche Herausforderung? Verallgemeinern Sie den Code der vorigen Übung und erstellen Sie ein Programm, das Zahlen aller Zahlensysteme zur Basis 16 oder kleiner ineinander umrechnen kann. Das Programm könnte beispielsweise vom Neunersystem ins Vierersystem konvertieren.

2.9

Schreiben Sie ein Programm, das eine Textzeile einliest, die Anzahl der darin enthaltenen Wörter ermittelt, die Buchstabenzahl des längsten Worts herausfindet, die größte Anzahl von Selbstlauten in einem Wort errechnet oder irgendeine andere Statistik erstellt, die Ihnen gerade einfällt.

Kapitel 3: Arrays

Im vorhergehenden Kapitel haben wir uns auf skalare Variablen beschränkt, also auf Variablen, die nur einen einzelnen Wert speichern. In diesem Kapitel werden wir Problemstellungen betrachten, in denen die gängigste zusammengesetzte Datenstruktur verwendet wird: das Array. Zwar handelt es sich bei Arrays um einfache Gebilde, die grundlegenden Einschränkungen unterliegen, dennoch vergrößert ihre Verwendung die Leistungsfähigkeit unserer Programme erheblich.

In diesem Kapitel werden wir uns vornehmlich mit „echten” Arrays beschäftigen, also solchen, die mit der C++-eigenen Syntax deklariert werden, wie etwa:

int tenIntegerArray[10];

Die vorgestellten Vorgehensweisen finden jedoch ebenso bei Datenstrukturen mit ähnlichen Eigenschaften Anwendung. Unter derartigen Datenstrukturen sind Vektoren am gebräuchlichsten. Der Begriff Vektor wird oft als Synonym für eindimensionale Arrays verwendet, aber wir bezeichnen damit im engeren Sinne eine Struktur mit den Eigenschaften eines Arrays, bei der die maximale Anzahl der Elemente nicht festgelegt ist. Bei unseren Überlegungen ist ein Array von fester Größe, während ein Vektor bei Bedarf automatisch wachsen oder schrumpfen kann. Die in diesem Kapitel vorgestellten Problemstellungen beinhalten jeweils Beschränkungen, die es uns gestatten, eine Struktur zu verwenden, bei der die Anzahl der Elemente festgelegt ist. Aufgabenstellungen ohne solche Einschränkungen könnten so angepasst werden, dass sie einen Vektor verwenden.

Darüber hinaus können die im Zusammenhang mit Arrays verwendeten Verfahren häufig mit Datenstrukturen eingesetzt werden, die nicht alle oben genannten Eigenschaften aufweisen. Bei einigen dieser Verfahren ist es beispielsweise nicht erforderlich, wahlfreien Zugriff auf die Elemente zu besitzen, sodass diese Verfahren auch mit Strukturen wie verketteten Listen einsetzbar sind. Weil Arrays bei der Programmierung ein so häufiges Thema und viele der zugehörigen Verfahren auch ohne unmittelbaren Bezug zu Arrays brauchbar sind, stellen Arrays eine ausgezeichnete „Spielwiese” zur Untersuchung der Problemlösung mittels Datenstrukturen dar.

3.1 Array-Grundlagen

Ihnen dürfte bereits bekannt sein, was ein Array ist, aber lassen Sie uns der Deutlichkeit halber kurz die Eigenschaften von Arrays rekapitulieren. Ein Array ist eine Sammlung von Variablen gleichen Datentyps, die durch einen einzigen Namen bezeichnet wird. Die einzelnen Variablen werden durch eine Zahl bezeichnet. Die einzelnen Variablen nennen wir die Elemente des Arrays. In C++ und den meisten anderen Programmiersprachen wird dem ersten Element die Zahl 0 zugeordnet; in einigen Sprachen ist dies jedoch anders.

Die wichtigste Eigenschaft eines Arrays folgt direkt aus der Definition. Alle in einem Array gespeicherten Werte sind vom gleichen Datentyp, während andere zusammengesetzte Datenstrukturen Werte unterschiedlichen Datentyps aufnehmen können. Auf ein einzelnes Element des Arrays wird über einen Index zugegriffen. Bei anderen Datenstrukturen lassen sich einzelne Elemente auch durch einen Namen oder einen Schlüsselwert ansprechen.

Von diesen primären Eigenschaften lassen sich verschiedene sekundäre Eigenschaften ableiten. Da jedem Element ein Index aus einer Zahlenfolge zugeordnet ist, die bei 0 beginnt, können wir alle Werte in einem Array auf einfache Weise untersuchen. Bei anderen Datenstrukturen ist dies manchmal schwierig, ineffizient oder sogar unmöglich. Außerdem erlauben Arrays wahlfreien Zugriff, was bedeutet, dass wir jederzeit auf alle Elemente des Arrays zugreifen können, während andere Datenstrukturen, wie etwa verkettete Listen, nur sequenziellen Zugriff gestatten.

Diese primären und sekundären Eigenschaften legen fest, auf welche Art und Weise wir Arrays verwenden können. Es ist beim Umgang mit zusammengesetzten Datenstrukturen immer nützlich, eine Reihe elementarer Operationen im Kopf zu haben, wenn Sie über Problemstellungen nachdenken. Stellen Sie sich diese grundlegenden Operationen als gebräuchliche Werkzeuge vor, sozusagen als Hammer, Säge und Schraubendreher der Datenstruktur. Natürlich lassen sich mit diesen einfachen Werkzeugen nicht alle mechanischen Defekte beheben, aber Sie sollten sich immer fragen, ob eine Aufgabe sich mit dem üblichen Handwerkszeug erledigen lässt, bevor Sie sich auf den Weg zum Baumarkt machen. Es folgt eine von mir zusammengestellte Liste der elementaren Operationen für Arrays.

3.1.1 Speichern

Hierbei handelt es sich um die grundlegendste aller Operationen. Ein Array ist eine Sammlung von Variablen und wir können jeder dieser Variablen einen Wert zuweisen. Um beispielsweise dem ersten Element (mit Index 0) des eben deklarierten Arrays die Ganzzahl 5 zuzuweisen, müssen Sie nur Folgendes schreiben:

tenIntegerArray[0] = 5;

Die Werte der Elemente des Arrays enthalten, wie jede andere Variable auch, undefiniertes „Gerümpel”, solange keine bestimmten Werte zugewiesen werden. Daher sollten Arrays vor der Verwendung initialisiert werden. In manchen Fällen, insbesondere beim Testen, ist es wünschenswert, jedem Element des Arrays einen bestimmten Wert zuzuweisen. Diese Initialisierung können wir bei der Deklaration des Arrays vornehmen:

tenIntegerArray[10] = {4, 5, 9, 12, -4, 0, -57, 30987, -287, 1};

Einen sinnvollen Einsatz einer solchen Initialisierung werden Sie in Kürze kennenlernen. Anstatt jedem Element des Arrays einen bestimmten Wert zuzuweisen, ist es gelegentlich erwünscht, alle Elemente mit demselben Wert zu initialisieren. Es gibt unter Umständen die Möglichkeit, das Zuweisen von 0 zu allen Elementen eines Arrays zu verkürzen, je nach Situation oder verwendetem Compiler (beispielsweise initialisiert der C++-Compiler von Microsoft Visual Studio sämtliche Werte aller Arrays mit 0, sofern nicht anders angegeben). Zum gegenwärtigen Zeitpunkt sollten wir jedoch ein Array immer ausdrücklich initialisieren, wenn zwecks Steigerung der Verständlichkeit eine Initialisierung erforderlich ist, wie im folgenden Code, der allen 10 Elementen eines Arrays den Wert -1 zuweist:

int tenIntegerArray[10];
for (int i = 0; i < 10; i++) tenIntegerArray[i] = -1;

3.1.2 Kopieren

Arrays können kopiert werden, was in zwei häufig vorkommenden Situationen nützlich ist. Zum einen möchte man oft umfassende Änderungen an einem Array vornehmen, benötigt das Array aber für die weitere Verarbeitung auch in seiner ursprünglichen Form. Das Array nach den Änderungen wieder in den Ausgangszustand zu versetzen, kann schwierig oder sogar unmöglich sein, wenn irgendeiner der Werte verändert wurde. Bei einer Kopie des gesamten Arrays haben wir die Möglichkeit, die Kopie zu modifizieren, und müssen das ursprüngliche Array nicht antasten. Wir benötigen dazu, wie bei der Initialisierung, nur eine Schleife und einen Zuweisungsbefehl:

int tenIntegerArray[10] = {4, 5, 9, 12, -4, 0, -57, 30987, -287, 1};
int secondArray[10];
for (int i = 0; i < 10; i++) secondArray[i] = tenIntegerArray[i];

Diese Operation steht für die meisten zusammengesetzten Datenstrukturen zur Verfügung. Die zweite der erwähnten Situationen betrifft vornehmlich Arrays. Hin und wieder möchte man einen Teil der Daten von einem Array in ein anderes kopieren oder aber die Elemente eines Arrays in ein zweites Array kopieren, um die Reihenfolge der Elemente umzusortieren. Falls Sie den Mergesort-Algorithmus (englisch to merge, verschmelzen und to sort, sortieren) kennen, haben Sie die Arbeitsweise dieses Verfahrens vermutlich schon in Aktion gesehen. Später in diesem Kapitel folgen auch noch Beispiele für das Kopieren.

3.1.3 Zugriff und Suche

Wenn wir Werte in einem Array ablegen können, müssen wir diese natürlich auch wieder herausholen können. Der Zugriff auf den Wert eines bestimmten Elements ist unkompliziert:

int num = tenIntegerArray[0];

Suche nach einem bestimmten Wert

Für gewöhnlich ist das natürlich nicht ganz so einfach. Oftmals kennen wir den benötigten Index nicht und wir müssen das Array durchsuchen, um die Position eines bestimmten Werts zu finden. Wenn sich die Elemente des Arrays nicht in einer speziellen Reihenfolge befinden, ist eine sequenzielle Suche das Mittel der Wahl. Dabei werden alle Elemente des Arrays der Reihe nach überprüft, bis der gesuchte Wert gefunden ist. Nachstehend eine einfache Version dieses Verfahrens.

const int ARRAY_SIZE = 10;
int intArray[ARRAY_SIZE] = {4, 5, 9, 12, -4, 0, -57, 30987, -287, 1};
int targetValue = 12;
int targetPos = 0;
while ((intArray[targetPos] != targetValue) && (targetPos < ARRAY_SIZE))
    targetPos++;

In diesem Codeabschnitt finden Sie eine Konstante zum Speichern der Größe des Arrays , das Array selbst , eine Variable, die den gesuchten Wert enthält , sowie eine weitere Variable zum Speichern des Index, an der der gesuchte Wert gefunden wird . In diesem Beispiel verwenden wir die Konstante ARRAY_SIZE, um die Schleife abzubrechen , wenn sich der Suchwert targetValue nicht unter den Elementen des Arrays befindet. Sie könnten statt der Konstanten ARRAY_SIZE auch den Wert 10 „fest verdrahten”, aber durch die Verwendung der Konstanten wird der Code allgemein gehalten und vereinfacht eine künftige Modifizierung und Wiederverwendung. Wir werden in den meisten Codebeispielen dieses Kapitels eine solche Konstante verwenden. Beachten Sie, dass targetPos nach dem Beenden der Schleife den Wert von ARRAY_SIZE besitzt, wenn targetValue in intArray nicht gefunden wird. Auch das reicht bereits aus, um anzuzeigen, dass die Suche erfolglos war, denn ARRAY_SIZE ist dann kein gültiger Index des Arrays. Dies zu überprüfen, bleibt jedoch nachfolgendem Code vorbehalten. Beachten Sie außerdem, dass der Code keinen Versuch unternimmt, mehr als ein Vorkommen des gesuchten Werts zu entdecken. Sobald der gesuchte Wert zum ersten Mal auftaucht, wird die Schleife beendet.

Suchkriterien

Es kommt vor, dass der gesuchte Wert noch gar nicht feststeht, sondern von anderen Werten des Arrays abhängt. Sie könnten beispielsweise auf der Suche nach dem größten Wert des Arrays sein. Ich bezeichne die Methode zum Lösen dieser Aufgabe in Anspielung auf das Kinderspiel als „König der Murmelspieler”. Verwenden Sie eine Schleife, um alle Werte des Arrays zu überprüfen, und weisen Sie bei jedem Durchlauf einer Variablen den jeweils höchsten bis zu diesem Zeitpunkt gefundenen Wert zu. Wenn Sie einen höheren Wert als den bislang höchsten finden, wird der „König der Murmelspieler” seines Amtes enthoben und der neue Wert nimmt seinen Platz ein:

const int ARRAY_SIZE = 10;
int intArray[ARRAY_SIZE] = {4, 5, 9, 12, -4, 0, -57, 30987, -287, 1};
int highestValue = intArray[0];
for (int i = 1; i < ARRAY_SIZE; i++) {
   if (intArray[i] > highestValue ) highestValue = intArray[i];
}

Die Variable highestValue speichert den höchsten bislang gefundenen Wert. Bei der Deklaration wird ihr der Wert des ersten Elements des Arrays zugewiesen . Das erlaubt es uns, die Schleife mit dem zweiten Wert des Arrays zu starten (also i = 1 statt i = 0 zu verwenden) . Im Inneren der Schleife vergleichen wir den aktuellen Wert mit highestValue und ersetzen highestValue durch diesen, wenn er größer ist. Beachten Sie, dass nur der Vergleichsoperator „größer als” durch ein „kleiner als” ersetzt werden muss, um statt des größten den kleinsten Wert zu finden. (Natürlich sollte auch die Bezeichnung der Variablen geändert werden, um keine Verwirrung zu stiften.) Diese Grundstruktur lässt sich in allen möglichen Situationen verwenden, in denen wir sämtliche Elemente eines Arrays prüfen möchten, um einen Wert mit einer bestimmten Eigenschaft zu finden.

3.1.4 Sortieren

Sortieren bedeutet, die Daten in eine bestimmte Reihenfolge zu bringen. Möglicherweise sind Ihnen schon Sortieralgorithmen für Arrays begegnet. Da es so viele miteinander konkurrierende Sortieralgorithmen gibt, handelt es sich hier um ein klassisches Thema der Leistungsanalyse. Jeder Sortieralgorithmus besitzt eigene Leistungscharakteristiken, die von den Eigenschaften der zugrunde liegenden Daten abhängen. Eine Untersuchung der verschiedenen Sortieralgorithmen bietet genügend Stoff für ein eigenes Buch, daher können wir dieses Thema nicht erschöpfend behandeln. Stattdessen konzentrieren wir uns auf das Praxistaugliche. Bei den meisten Aufgaben kommt man mit zwei Sortieralgorithmen im Werkzeugkasten aus: einer schnellen, leicht einsetzbaren Sortierfunktion und einem vernünftigen, leicht verständlichen Algorithmus, der sich bei Bedarf auf unkomplizierte Weise modifizieren lässt. Für die schnelle und einfache Sortierung verwenden wir die Funktion qsort aus der Standardbibliothek, und wenn wir Änderungen vornehmen müssen, setzen wir einen Insertionsort ein.

Schnelles und einfaches Sortieren mit qsort

C/C++-Programmierer verwenden standardmäßig die Funktion qsort aus der Standardbibliothek zum schnellen Sortieren. (Der Name der Funktion lässt vermuten, dass hier der Quicksort-Algorithmus zum Einsatz kommt, das ist aber nicht notwendigerweise bei allen Implementierungen der Fall.) Um qsort verwenden zu können, müssen wir eine Vergleichsfunktion programmieren, die von qsort immer dann aufgerufen wird, wenn es zwei Elemente des Arrays miteinander vergleichen und feststellen muss, welches davon in der sortierten Reihenfolge zuerst erscheinen soll. Der Funktion werden zwei void-Zeiger übergeben. Zeiger sind in diesem Buch zwar noch nicht zur Sprache gekommen, Sie müssen hier aber nur wissen, dass Sie eine Typumwandlung der void-Zeiger in den Datentyp der Elemente des Arrays vornehmen müssen. Die Vergleichsfunktion sollte dann einen int-Wert zurückgeben, der entweder positiv, negativ oder null ist, je nachdem ob das erste Element größer oder kleiner als das zweite Element ist bzw. beide gleich groß sind. Der genaue Wert ist ohne Belang, entscheidend ist nur, ob er positiv, negativ oder null ist. Sehen wir uns zur Verdeutlichung ein kurzes Beispiel an, in dem 10 Ganzzahlen mittels qsort sortiert werden:

int compareFunc(const void * voidA, const void * voidB) {
   int * intA = (int *)(voidA);
   int * intB = (int *)(voidB);
   return *intA - *intB;
}

Die Parameterliste besteht aus zwei const void-Zeigern . Dies ist bei einer Vergleichsfunktion immer der Fall. Der Code der Funktion deklariert zunächst zwei int-Zeiger und nimmt dann eine Typumwandlung der beiden void-Zeiger in int-Zeiger vor. Wir könnten die Funktion auch ohne die beiden temporären Variablen schreiben, aber ich verwende sie hier der Deutlichkeit halber. Der entscheidende Punkt ist, dass nach diesen Deklarationen die Zeiger intA und intB auf zwei Elemente des Arrays verweisen und *intA bzw. *intB die beiden Ganzzahlen sind, die es zu vergleichen gilt. Schließlich subtrahieren wir die zweite Ganzzahl von der ersten und geben das Resultat des Vergleichs zurück . Dadurch wird das erwünschte Ergebnis erreicht. Wenn beispielsweise *intA > *intB ist, soll eine positive Zahl zurückgegeben werden und *intA – *intB ist in diesem Fall auch positiv. Ebenso ist *intA - *intB negativ oder 0, wenn *intB > *intA ist bzw. beide Ganzzahlen gleich groß sind.

Nachdem die Vergleichsfunktion erstellt ist, sieht die Verwendung von qsort wie folgt aus:

const int ARRAY_SIZE = 10;
int intArray[ARRAY_SIZE] = {87, 28, 100, 78, 84, 98, 75, 70, 81, 68};
qsort(intArray, ARRAY_SIZE, sizeof(int), compareFunc);

Der Aufruf von qsort erfordert vier Parameter: das zu sortierende Array , die Anzahl der Elemente des Arrays , die Größe eines der Elemente des Arrays, die häufig mittels des sizeof-Operators ermittelt wird, und schließlich die Vergleichsfunktion . Beachten Sie die Syntax des letzten Parameters, wenn Sie über keine große Erfahrung in der Übergabe von Funktionen als Parameter für andere Funktionen verfügen. Wir übergeben die Funktion selbst und rufen diese nicht auf und übergeben dann das Resultat. Daher geben wir nur den Namen der Funktion an, und zwar ohne Parameterliste und ohne Klammern.

Leicht modifizierbares Sortieren per Insertionsort

Gelegentlich werden Sie den Code zum Sortieren eigenhändig schreiben müssen. In manchen Situationen sind die vorhandenen Sortierfunktionen einfach ungeeignet. Stellen Sie sich beispielsweise vor, dass Sie ein Array anhand der Daten in einem anderen Array sortieren müssen. Wenn Sie schon Ihren eigenen Code zum Sortieren schreiben müssen, möchten Sie sicherlich eine schlichte Sortierroutine verwenden, der Sie vertrauen und die Sie bei Bedarf verändern können. Ein Insertionsort ist hier eine vernünftige Lösung. Der Insertionsort funktioniert nach derselben Methode, mit der viele Leute ihre Spielkarten sortieren. Man nimmt die Karten einzeln auf und fügt diese den Karten in der Hand an der passenden Stelle hinzu, sodass diese sich stets in der richtigen Reihenfolge befinden, und verschiebt dabei gegebenenfalls andere Karten, um Platz zu schaffen. Nachstehendes Listing zeigt eine einfache Umsetzung für unser int-Array:

int start = 0;
int end = ARRAY_SIZE - 1;
for (int i = start + 1; i <= end; i++) {
   for (int j = i; j > start && intArray[j-1] > intArray[j]; j--) {
      int temp = intArray[j-1];
      intArray[j-1] = intArray[j];
      intArray[j] = temp;
   }
}

Zunächst deklarieren wir die Variablen start und end , die den Index des ersten und letzten Elements des Arrays bezeichnen. Das erhöht die Verständlichkeit des Codes und ermöglicht es außerdem, den Code später so zu modifizieren, dass er bei Bedarf nur Teile eines Arrays sortiert. Die äußere Schleife wählt die nächste „Spielkarte” aus, die in das stetig wachsende, sortierte Blatt in der Hand eingefügt werden soll . Beachten Sie, dass die Schleifenvariable i mit dem Wert start + 1 initialisiert wird. Denken Sie an den Code zum Auffinden des größten Werts, in dem wir die Variable, die den höchsten Wert enthält, mit dem ersten Wert des Arrays initialisiert haben und die Schleife mit dem zweiten Element des Arrays startet. Hier verhält es sich ganz genauso. Wenn es nur einen Wert (oder eine „Spielkarte”) gibt, ist dieser per definitionem bereits „sortiert” und wir können damit fortfahren, zu prüfen, ob der zweite Wert vor oder hinter den ersten gehört. Die innere Schleife fügt den aktuellen Wert an der richtigen Position ein, indem sie diesen wiederholt mit dem vorhergehenden Wert austauscht, bis der korrekte Ort erreicht ist. Die Schleifenvariable j beginnt mit dem Wert i und wird bis zum Erreichen des unteren Endes des Arrays oder dem Fund eines neuen Werts heruntergezählt. Bis es so weit ist, verwenden wir drei Zuweisungen, um den aktuellen Wert mit dem Wert auszutauschen, der sich im Array eine Position weiter unten befindet . Anders ausgedrückt: Wenn Sie ein Blatt mit 13 Spielkarten auf der Hand haben und die linken 4 Karten bereits sortiert sind, könnten Sie die Karte, die auf die fünfte Position von links gehört, in die korrekte Position bringen, indem Sie diese Karte so lange von rechts nach links mit der links befindlichen Karte austauschen, bis sich links von der Karte eine Karte niedrigeren Werts befindet. Genau das geschieht in der inneren Schleife. Die äußere Schleife wiederholt diesen Vorgang, ausgehend von der Karte ganz links, mit sämtlichen Spielkarten. Zum Schluss ist dann das gesamte Array sortiert.

Ein Insertionsort ist in den meisten Fällen nicht sonderlich leistungsstark, und um ehrlich zu sein, stellt der oben stehende Code auch nicht die leistungsfähigste Methode dar, um einen Insertionsort durchzuführen. Das Verfahren ist bei Arrays kleiner bis mittlerer Größe jedoch hinreichend schnell und so einfach, dass man es sich gut merken kann. Stellen Sie sich das Verfahren als mentales Makro vor. Ob Sie sich nun für diese Sortiermethode oder eine andere entscheiden: Wichtig ist, dass Sie eine (mindestens) ausreichend schnelle Sortierroutine verwenden, die Sie vertrauensvoll eigenhändig programmieren können. Es genügt nicht, über Code anderer Programmierer zum Sortieren zu verfügen, wenn Sie diesen Code nicht vollständig verstehen. Man sollte besser nicht an Mechanismen „herumbasteln”, die man nicht vollkommen durchschaut.

3.1.5 Statistische Werte

Die letzte Aufgabe ähnelt dem Zugriff insofern, als wir alle Elemente des Arrays untersuchen müssen, bevor wir Resultate liefern können. Sie unterscheidet sich hingegen vom Zugriff dadurch, dass es sich nicht nur um den Wert eines einzelnen Elements handelt, sondern um statistische Werte, zu deren Berechnung sämtliche Werte des Arrays herangezogen werden. Wir könnten beispielsweise den Durchschnitt, den Medianwert oder den Modalwert berechnen und werden das später in diesem Kapitel auch tun. Ein einfacher statistischer Wert ist der Durchschnittswert von Schulnoten:

const int ARRAY_SIZE = 10;
int gradeArray[ARRAY_SIZE] = {87, 76, 100, 97, 64, 83, 88, 92, 74, 95};
double sum = 0;
for (int i = 0; i < ARRAY_SIZE; i++) {
    sum += gradeArray[i];
}
double average = sum / ARRAY_SIZE;

Ein weiteres einfaches Beispiel ist die Überprüfung von Daten. Denken Sie dabei an ein Array von double-Werten mit der Bezeichnung vendorPayments, in dem Zahlungen an einen Lieferanten gespeichert sind. Das Array darf nur positive Werte enthalten, daher würde das Vorhandensein negativer Werte bedeuten, dass die Daten nicht mehr intakt sind. Zum Erstellen eines Prüfberichts könnten wir dann eine Schleife programmieren, in der die negativen Werte in diesem Array gezählt werden:

const int ARRAY_SIZE = 10;
int countNegative = 0;
for (int i = 0; i < ARRAY_SIZE; i++) {
    if (vendorPayments[i] < 0) countNegative++;
}

3.2 Aufgabenstellungen mit Arrays

Sobald Sie die üblichen Operationen verstanden haben, unterscheidet sich das Lösen von Aufgabenstellungen mit Arrays nicht sonderlich vom Lösen von Aufgaben mit einfachen Daten, wie Sie sie aus dem vorhergehenden Kapitel kennen. Lassen Sie uns ein Beispiel betrachten und beim Durcharbeiten die Verfahren aus den vorhergehenden Kapiteln sowie die möglicherweise erforderlichen Array-Operationen einsetzen.

Aufgabe: Modalwert berechnen

In der Statistik ist der Modalwert einer Reihe von Werten derjenige Wert, der am häufigsten vorkommt. Schreiben Sie Code, der ein Array mit Umfragedaten auswertet und den Modalwert errechnet. Die befragten Personen mussten in dieser Umfrage eine Frage mit einer Zahl im Wertebereich von 1 bis 10 beantworten. Falls es mehrere Modalwerte gibt, kann ein beliebiger ausgewählt werden.

Bei dieser Aufgabenstellung müssen wir einen der Werte des Arrays als Resultat liefern. Wenn wir nach Analogien suchen und mit Bekanntem anfangen, dürfen wir vielleicht darauf hoffen, dass wir eine der uns bereits geläufigen Methoden zum Auffinden von Werten einsetzen können: die Suche nach dem größten Wert in einem Array. Dieser Code speichert den größten bekannten Wert in einer Variablen und vergleicht alle folgenden Werte mit dieser Variablen, deren Wert, falls erforderlich, ersetzt wird. Ein analoges Verfahren würde hier bedeuten, den häufigsten Wert in einer Variablen zu speichern und diesen zu ersetzen, sobald wir einen Wert im Array finden, der noch häufiger vorkommt. Formuliert man das in natürlicher Sprache, hört es sich vielversprechend an, aber wenn wir Überlegungen zum eigentlichen Code anstellen, wird das Problem deutlich. Werfen wir einen Blick auf ein Array und dessen Konstante zur Beschreibung der Größe:

const int ARRAY_SIZE = 12
int surveyData[ARRAY_SIZE] = {4, 7, 3, 8, 9, 7, 3, 9, 9, 3, 3, 10};

Der Modalwert dieser Daten ist 3, denn der Wert 3 kommt vier Mal vor, also öfter als alle anderen Werte. Wenn wir dieses Array jedoch, wie bei der Suche nach dem größten Wert, sequenziell verarbeiten, wie sollen wir dann entscheiden, dass 3 der Modalwert ist? Und wie können wir beim vierten Auftauchen der 3 wissen, dass es sich wirklich um das vierte und letzte Vorkommen handelt? Es scheint keinen Weg zu geben, mit einer einzigen, sequenziellen Verarbeitung der Arraydaten an diese Informationen zu gelangen.

Wenden wir uns also einer der anderen Techniken zu, dem Vereinfachen des Problems. Und wenn wir uns das Leben leichter machen, indem wir alle Vorkommen derselben Zahl nebeneinander stellen? Die Beispieldaten sehen dann so aus:

int surveyData[ARRAY_SIZE] = {4, 7, 7, 9, 9, 9, 8, 3, 3, 3, 3, 10 };

Nun stehen die beiden Siebenen, die Neunen und die Dreien jeweils nebeneinander. Wenn die Daten auf diese Weise gruppiert sind, sollten wir in der Lage sein, den Modalwert durch sequenzielle Verarbeitung des Arrays zu finden. Wenn man das Array im Geiste verarbeitet, lassen sich die Vorkommen der verschiedenen Werte leicht zählen, weil man das Array einfach der Reihe nach durchgeht, bis man die erste Zahl findet, die sich von der bisherigen unterscheidet. Die Vorgänge im Kopf in Anweisungen für ein Programm zu konvertieren, kann allerdings eine knifflige Angelegenheit sein. Bevor wir versuchen, den Code für das vereinfachte Problem zu schreiben, werden wir daher Pseudocode schreiben. Dabei handelt es sich um programmähnliche Anweisungen, die sich irgendwo zwischen C++ und natürlicher Sprache bewegen. Der Pseudocode soll uns daran erinnern, was wir mit den Anweisungen eigentlich erreichen wollen.

int mostFrequent = ?;
int highestFrequency = ?;
int currentFrequency = 0;
for (int i = 0; i < ARRAY_SIZE; i++) {
   currentFrequency++;
   if (surveyData[i] IST LETZTES VORKOMMEN EINES WERTS) {
      if (currentFrequency > highestFrequency) {
      highestFrequency = currentFrequency;
      mostFrequent = surveyData[i];
      }
      currentFrequency = 0;
   }
}

Es gibt keine richtige oder falsche Art und Weise, Pseudocode zu verfassen, und falls Sie diese Technik nutzen, sollten Sie sich Ihren eigenen Stil angewöhnen. Ich neige beim Schreiben von Pseudocode dazu, die Anweisungen, derer ich mir schon sicher bin, als korrektes C++ zu schreiben und die Stellen, an denen ich mir noch Gedanken machen muss, in natürlicher Sprache abzufassen. Im vorliegenden Fall ist bekannt, dass wir eine Variable benötigen (mostFrequent), die den bislang häufigsten Wert speichert und am Ende der Schleife den Modalwert enthält, wenn alles ordentlich programmiert ist. Wir benötigen eine weitere Variable, die speichert, wie oft der Wert vorkommt (highestFrequency), damit wir einen Vergleich vornehmen können. Und schließlich brauchen wir eine Variable, um zu zählen, wie oft der aktuelle Wert schon vorgekommen ist, wenn wir das Array sequenziell verarbeiten (currentFrequency). Außerdem wissen wir, dass wir die Variablen initialisieren müssen. currentFrequency erhält den Wert 0, aber solange noch Teile des Codes fehlen, ist unklar, wie die anderen Variablen zu initialisieren sind. Daher kommen an diese Stellen Fragezeichen , um uns daran zu erinnern, dort später noch einmal nachzusehen.

Die Schleife selbst entspricht der bereits bekannten und steht hier schon in der endgültigen Fassung . Innerhalb der Schleife erhöhen wir die Variable zum Zählen der Häufigkeit des aktuellen Werts und dann kommen wir zum Dreh- und Angelpunkt. Es ist klar, dass wir prüfen müssen, ob das letzte Vorkommen eines Werts erreicht ist . Der Pseudocode erlaubt es uns, die hier nötige Programmlogik später herauszufinden und die Funktion des fehlenden Codes nur zu umreißen. Handelt es sich tatsächlich um das letzte Vorkommen des Werts, wissen wir auch schon, was zu tun ist, weil es sich hier wie bei der Suche nach dem größten Wert verhält: Wir müssen prüfen, ob die Anzahl des Werts größer ist als diejenige des bislang häufigsten. Ist das der Fall, wird der aktuelle Wert der neue häufigste Wert . Dann setzen wir den Zähler zurück , denn der nächste eingelesene Wert wird das erste Vorkommen eines neuen Werts sein.

Zurück zur Programmlogik der if-Anweisung, die wir eben übersprungen haben. Wie können wir feststellen, ob es sich um das letzte Vorkommen eines Werts im Array handelt? Da die Werte des Arrays jetzt gruppiert sind, muss es sich um das letzte Vorkommen handeln, wenn der folgende Wert sich vom aktuellen unterscheidet. Oder als C++ formuliert: wenn surveyData[i] != surveyData[i+1]. Darüber hinaus ist der letzte Wert des Arrays auch das letzte Vorkommen dieses Werts, obwohl es keinen nächsten Wert gibt. Das können wir prüfen, indem wir testen, ob i == ARRAY_SIZE – 1 ist. In diesem Fall handelt es sich um den letzten Wert des Arrays.

Nachdem wir all dies herausgefunden haben, wollen wir uns ein paar Gedanken über die Startwerte der Variablen machen. Bei der Suche nach dem größten Wert haben wir die Variable, die den bislang größten Wert speichert, mit dem ersten Wert des Arrays initialisiert. Hier wird der am häufigsten vorgekommene Wert durch zwei Variablen repräsentiert, nämlich mostFrequent für den Wert selbst und highestFrequency für die Anzahl der Vorkommen dieses Werts. Es wäre schön, wenn wir mostFrequent mit dem ersten Wert des Arrays und highestFrequency mit der Anzahl seiner Vorkommen initialisieren könnten, aber es gibt keine Möglichkeit, die Anzahl der Vorkommen des ersten Werts in Erfahrung zu bringen, solange die Schleife noch nicht erreicht ist und mit dem Zählen begonnen wird. Nun wird die Anzahl der Vorkommen des ersten Werts auf jeden Fall größer als 0 sein. Wenn wir also highestFrequency auf 0 setzen, wird unser Code die Werte von mostFrequent und highestFrequency ohnehin durch die Zahlen für den ersten Wert ersetzen, sobald wir dessen letztes Vorkommen erreichen. Zusammengefasst sieht der Code nun wie folgt aus:

int mostFrequent;
int highestFrequency = 0;
int currentFrequency = 0;
for (int i = 0; i < ARRAY_SIZE; i++) {
   currentFrequency++;
   // if (surveyData[i] IST LETZTES VORKOMMEN EINES WERTS)
   if (i == ARRAY_SIZE - 1 || surveyData[i] != surveyData[i + 1]) {
      if (currentFrequency > highestFrequency) {
         highestFrequency = currentFrequency;
         mostFrequent = surveyData[i];
      }
      currentFrequency = 0;
   }
}

In diesem Buch sind reine Stilfragen, wie etwa die Art der Dokumentierung (Kommentare), eigentlich nebensächlich, aber da wir bei dieser Aufgabenstellung Pseudocode verwenden, möchte ich einen Tipp loswerden. Mir ist aufgefallen, dass die Zeilen, die ich im Pseudocode in natürlicher Sprache stehen lasse, im endgültigen Code am meisten von einem Kommentar profitieren, wobei die ursprüngliche natürliche Sprache selbst einen ausgezeichneten Kommentar darstellt. Ich möchte das mit dem obenstehenden Code demonstrieren. Die genaue Bedeutung der Bedingung in der if-Anweisung ist schnell vergessen, aber der Kommentar in der Zeile darüber erklärt sie in angenehmer Weise.

Was den Code selbst betrifft: Er erledigt zwar seine Aufgabe, macht es jedoch erforderlich, dass die Umfragedaten gruppiert sind. Die Gruppierung der Daten könnte eine eigene Aufgabenstellung sein, es sei denn – wir sortieren das Array! Wir benötigen die Daten zwar gar nicht in sortierter Reihenfolge, die Sortierung erledigt aber quasi nebenbei auch die benötigte Gruppierung. Da wir nicht beabsichtigen, irgendwelche speziellen Sortierungen vorzunehmen, genügt es, unserem Code den folgenden qsort-Aufruf voranzustellen:

qsort(surveyData, ARRAY_SIZE, sizeof(int), compareFunc);

Beachten Sie, dass wir dieselbe Vergleichsfunktion compareFunc verwenden, die wir früher für die Verwendung mit qsort programmiert haben. Nachdem die Sortierung eingebaut ist, haben wir eine vollständige Lösung für die ursprüngliche Aufgabenstellung. Die Arbeit ist also erledigt. Oder?

3.2.1 Refactoring

Manche Programmierer sprechen gelegentlich von „übelriechendem” Code und beziehen sich dabei auf funktionierenden und fehlerfreien Code, der nichtsdestotrotz in gewisser Weise problematisch ist. In manchen Fällen ist damit Code gemeint, der zu kompliziert ist oder zu viele Sonderfälle behandelt, was es einem Programmierer erschwert, das Programm zu modifizieren und zu warten. In anderen Fällen ist der Code nicht so leistungsfähig, wie er es sein könnte. Der Code funktioniert zwar mit Testdaten, aber dennoch befürchtet der Programmierer einen Leistungseinbruch, wenn echte, umfangreichere Daten verarbeitet werden sollen. Und darüber mache ich mir hier Sorgen. Die Sortierung der wenigen Testdaten findet nahezu augenblicklich statt, aber was geschieht, wenn das Array richtig groß ist? Außerdem ist mir bekannt, dass der von qsort vermutlich verwendete Quicksort-Algorithmus am langsamsten wird, wenn das Array viele doppelte Werte enthält und die Aufgabenstellung besagt ja ausdrücklich, dass nur Werte von 1 bis 10 vorkommen. Ich halte daher ein Refactoring des Codes für angebracht. Das bedeutet, den Code zu verbessern, ohne zu ändern, was der Code erledigt, sondern nur, wie er dies tut. Ich wünsche mir eine Lösung, die auch bei großen Arrays, die nur Werte von 1 bis 10 enthalten, möglichst leistungsfähig ist.

Stellen wir ein paar Überlegungen zu den Operationen an, die wir mit Arrays durchführen können. Wir haben nun schon mehrere Versionen des Codes zum Auffinden des größten Werts untersucht und wissen, dass sich die dort verwendete Methode nicht direkt auf das surveyData-Array übertragen lässt. Gibt es möglicherweise ein Array, auf das wir die „Standard”-Methode zur Suche des größten Werts anwenden können, um den Modalwert der Umfragedaten zu finden? Die Antwort lautet ja. Wir brauchen ein Histogramm der Umfragedaten, eine grafische Darstellung der Häufigkeitsverteilung, die zeigt, wie oft die verschiedenen Werte in den zugrunde liegenden Daten vorkommen, und unser Array soll die Daten für ein solches Histogramm liefern. Wir werden also in einem Array mit 10 Elementen speichern, wie oft die einzelnen Werte von 1 bis 10 in surveyData vorkommen. Zum Erstellen des Histogramms verwenden wir folgenden Code:

const int MAX_RESPONSE = 10;
int histogram[MAX_RESPONSE];
for (int i = 0; i < MAX_RESPONSE; i++) {
    histogram[i] = 0;
}
for (int i = 0; i < ARRAY_SIZE; i++) {
    histogram[surveyData[i] - 1]++;
}

Zunächst deklarieren wir das Array, das die Histogrammdaten aufnimmt . Sie werden bemerkt haben, dass wir ein Array mit 10 Elementen deklarieren. Nun ist der Wertebereich der Umfragedaten 1 bis 10, während die Indizes des Arrays von 0 bis 9 laufen. Das müssen wir berücksichtigen und daher die Anzahl der Einsen in histogram[0], die Anzahl der Zweien in histogram[1] usw. speichern. (Manche Programmierer würden hier ein Array mit 11 Elementen verwenden und das Element [0] einfach leer lassen, damit Index und Wert übereinstimmen.) Wir initialisieren die Werte des Arrays in einer Schleife explizit mit 0 und können nun damit fortfahren, in einer weiteren Schleife die Vorkommen der einzelnen Werte in surveyData zu zählen . Die Anweisung in dieser Schleife müssen wir uns genau ansehen. Wir verwenden den Wert der aktuellen Position in surveyData dazu, die Position in histogram festzulegen, deren Wert erhöht werden soll. Machen wir uns das anhand eines Beispiels klar. Nehmen wir an, i hat den Wert 42. Wir sehen uns surveyData[42] an und finden den Wert 7. Wir müssen also den Zähler für Siebenen erhöhen. Von 7 subtrahieren wir 1, denn der Zähler für Siebenen befindet sich im Array histogram an Position [6], und histogram[6] wird erhöht.

Nun sind die Histogrammdaten erledigt und wir können uns dem verbleibenden Code zuwenden. Beachten Sie, dass der Histogrammcode separat erstellt wurde und daher auch unabhängig vom übrigen Code getestet werden kann. Es bringt keine Zeitersparnis, sämtlichen Code auf einen Schlag zu schreiben, wenn die Situation es erlaubt, die Aufgabenstellung in Teilaufgaben zu unterteilen, die sich unabhängig voneinander programmieren und testen lassen. Nach dem Testen des oben stehenden Codes suchen wir nun nach dem größten Wert im histogram-Array:

int mostFrequent = 0;
for (int i = 1; i < MAX_RESPONSE; i++) {
    if (histogram[i] > histogram[mostFrequent]) mostFrequent = i;
}
mostFrequent++;

Auch wenn es sich hier um eine Anpassung des Codes zum Suchen des größten Werts handelt, so gibt es doch einen Unterschied. Wir suchen zwar nach dem größten Wert im histogram-Array, interessieren uns aber nicht für den Wert selbst, sondern für dessen Position. Mit anderen Worten: Bei unseren Beispieldaten wollen wir wissen, dass die 3 öfter als alle anderen Werte vorkommt, aber die tatsächliche Anzahl der Dreien ist unwichtig. mostFrequent gibt daher die Position des größten Werts in histogram an, nicht den größten Wert. Wir initialisieren daher die Variable mit 0 und nicht die Position [0] in histogram. In der if-Anweisung findet deshalb auch der Vergleich mit histogram[mostFrequent] statt, nicht mit mostFrequent selbst, und wir weisen mostFrequent den Wert von i, nicht von histogramm[i], zu , wenn ein größerer Wert gefunden wird. Schließlich wird noch mostFrequent um 1 erhöht . Dabei handelt es sich um die Umkehrung dessen, was wir in der vorhergehenden Schleife gemacht haben, nämlich 1 zu subtrahieren, um die korrekte Position zu erhalten. Wenn beispielsweise mostFrequent darauf hinweist, dass die Position mit dem größten Wert 5 lautet, bedeutet dies, dass die 6 der häufigste Wert in den Umfragedaten ist.

Die Histogrammlösung skaliert linear mit der Anzahl der Elemente im Array surveyData. Das hätten wir kaum zu hoffen gewagt. Diese Lösung ist also besser als der ursprüngliche Ansatz, was allerdings nicht heißen soll, dass es sich dabei um einen Fehler oder Zeitverschwendung gehandelt hat. Es wäre natürlich durchaus möglich gewesen, diesen Code ohne die vorherige Version zu schreiben, und dass man sich wünscht, das Ziel ohne Umwege zu erreichen, ist verständlich. Dennoch kann ich nur davor warnen, sich vor die Stirn zu schlagen, wenn sich herausstellt, dass der erste Ansatz nicht die endgültige Lösung darstellt. Das erstmalige Schreiben eines Programms (erstmalig für den Programmierer) ist ein Lernprozess und man darf nicht erwarten, schnurstracks Fortschritte zu machen. Es kommt außerdem häufig vor, dass die Umwege, die man bei der einen Aufgabenstellung geht, später dabei helfen, ein anderes Problem schneller zu lösen. In diesem speziellen Fall könnte unsere ursprüngliche Lösung (obwohl sie nicht besonders gut skaliert) durchaus die richtige sein, wenn die möglichen Antworten der Umfrage nicht streng auf den kleinen Wertebereich von 1 bis 10 beschränkt wären. Oder nehmen wir an, dass Sie später darum gebeten werden, Code zur Berechnung des Medianwerts einer Reihe von Ganzzahlen zu schreiben. (Der Medianwert ist derjenige Wert in der Mitte, über und unter dem sich jeweils die Hälfte der anderen Werte befinden.) Die Idee, ein Histogramm zu verwenden, bringt Sie beim Medianwert nicht weiter, der ursprüngliche Ansatz beim Modalwert aber sehr wohl.

Es bleibt festzuhalten, dass Umwege keine Zeitverschwendung sind, wenn Sie dabei etwas lernen, was Ihnen beim direkten Weg zum Ziel entgangen wäre. Damit liegt auch ein weiterer Grund vor, warum es hilfreich sein kann, sämtlichen Code, den Sie schreiben, systematisch zwecks späterer Wiederverwendung zu sichern. Selbst Code, der bei einer Aufgabe in eine Sackgasse führt, kann sich bei einem anderen Problem als wertvolle Ressource erweisen.

3.3 Arrays mit fest vorgegebenen Daten

Bei den meisten Aufgabenstellungen, in denen Arrays vorkommen, dient das Array als Aufbewahrungsort externer Daten, wie etwa Benutzereingaben, Daten auf lokalen Datenträgern oder Daten auf einem Server. Um die Möglichkeiten von Arrays auszureizen, müssen Sie jedoch auch andere Situationen erkennen können, in denen ein Array einsetzbar ist. Es ist oft nützlich, Arrays zu verwenden, bei denen sich die Werte nach der Initialisierung nie mehr ändern. Ein solches Array kann mittels einer einfachen Schleife oder sogar durch direktes Nachschlagen im Array einen ganzen Block von Kontrollstrukturen ersetzen.

Im endgültigen Code der Aufgabe „Entschlüsseln einer Botschaft” (Abschnitt 2.4) verwenden wir im Satzzeichenmodus eine switch-Anweisung, um den entschlüsselten Eingabewert im Bereich von 1 bis 8 in das zugehörige Zeichen zu konvertieren, da die Zuordnung zwischen den Zahlen und Zeichen keiner festen Regel gehorcht. Das funktioniert zwar tadellos, macht den Codeabschnitt aber deutlich länger als den entsprechenden Code für den Groß- und Kleinbuchstabenmodus. Der Code skaliert außerdem schlecht, wenn die Anzahl der Satzzeichen steigt. Statt der switch-Anweisung können wir ein Array verwenden, um eine solche Aufgabe zu lösen. Zunächst müssen wir einem Array dauerhaft die Satzzeichen zuweisen, und zwar in derselben Reihenfolge, in der diese auch im Code erscheinen:

const char punctuation[8] = {'!', '?', ',', '.', ' ', ';', '"', '\''};

Beachten Sie, dass das Array als const deklariert wird, da sich die Werte niemals ändern. Mit dieser Deklaration können wir die gesamte switch-Anweisung durch eine einzige Zuweisung ersetzen, die auf das Array verweist:

outputCharacter = punctuation[number - 1];

Die Eingabewerte liegen zwischen 1 und 8, während die Elemente des Arrays von 0 bis 7 nummeriert sind, daher müssen wir vor dem Verweis auf das Array 1 subtrahieren. Es handelt sich um die gleiche Anpassung, die wir beim Programm zum Berechnen des Modalwertes (in der Variante, die ein Histogramm verwendet) vorgenommen haben. Sie können dieses Array auch zur Konvertierung in umgekehrter Richtung verwenden. Nehmen wir an, dass wir die Botschaft nicht ent-, sondern verschlüsseln möchten, wir also eine Reihe von Zeichen erhalten, die gemäß den Regeln der ursprünglichen Aufgabe in Zahlen konvertiert werden sollen. Um ein Satzzeichen in die entsprechende Zahl zu konvertieren, müssen wir feststellen, an welcher Position im Array sich das Zeichen befindet. Eine solche Abfrage können wir mittels der sequenziellen Suchtechnik durchführen. Wenn wir annehmen, dass die char-Variable targetValue das zu konvertierende Zeichen enthält, können wir die sequenzielle Suche wie folgt anpassen:

const int ARRAY_SIZE = 8;
int targetPos = 0;
while (punctuation[targetPos] != targetValue && targetPos < ARRAY_SIZE)
    targetPos++;
int punctuationCode = targetPos + 1

Beachten Sie, dass wir in diesem Beispiel zur Position im Array wieder 1 addieren müssen, ebenso wie wir im vorhergehenden Beispiel 1 von number subtrahieren mussten, um den Zeichencode des Satzzeichens zu erhalten, weil wir vom Wertebereich 0 bis 7 des Arrays in den Wertebereich 1 bis 8 der Satzzeichen konvertieren. Dieser Code ist zwar nicht so simpel wie der Einzeiler zur Konvertierung in die andere Richtung, er ist aber immer noch erheblich einfacher als eine Reihe von switch-Anweisungen und skaliert außerdem gut. Würden wir die Anzahl der möglichen Satzzeichen verdoppeln, müsste auch die Anzahl der Elemente im Array verdoppelt werden, die Länge des Codes hingegen bliebe gleich.

Im Allgemeinen sind const-Arrays somit bestens als Wertetabellen geeignet und können umständliche Folgen von Kontrollstrukturen ersetzen. Angenommen, Sie schreiben ein Programm, das die Kosten einer Konzessionsabgabe berechnet, wobei deren Höhe an den Bruttoumsatz geknüpft ist (Tabelle 3.1).

Gewerbekategorie | Bruttoumsatzgrenze | Konzessionsabgabe
I | 0 € | 25 €
II | 50.000 € | 200 €
III | 150.000 € | 1.000 €
IV | 500.000 € | 5.000 €

Tabelle 3.1: Höhe der Konzessionsabgabe in Abhängigkeit vom Umsatz

Bei dieser Aufgabenstellung könnten wir sowohl zur Ermittlung der Gewerbekategorie anhand des Bruttoumsatzes einer Firma als auch zur Bestimmung der Konzessionsabgabe mittels der Gewerbekategorie Arrays verwenden. Nehmen wir an, die double-Variable grossSales speichert den Bruttoumsatz einer Firma und anhand der Umsatzzahlen sollen der int-Variablen category und der double-Variablen cost die korrekten Werte zugewiesen werden:

const int NUM_CATEGORIES = 4;
const double categoryThresholds[NUM_CATEGORIES ] =
    {0.0, 50000.0, 150000.0, 500000.0};
const double licenseCost[NUM_CATEGORIES ] =
    {50.0, 200.0, 1000.0, 5000.0};
category = 0;
while (category < NUM_CATEGORIES &&
    categoryThresholds[category] <= grossSales) {
       category++;
}
cost = licenseCost[category - 1];

Der Code verwendet zwei Arrays mit fest vorgegebenen Daten. Im ersten Array sind die Bruttoumsatzgrenzen der verschiedenen Gewerbekategorien gespeichert . Eine Firma mit 65.000 € Bruttoumsatz jährlich beispielsweise gehört zur Kategorie II, denn dieser Betrag übersteigt die Bruttoumsatzgrenze von 50.000 € dieser Kategorie, liegt aber noch unter der Bruttoumsatzgrenze der Kategorie III von 150.000 €. Das zweite Array speichert die Höhe der Konzessionsabgabe für die verschiedenen Kategorien . Wenn die Arrays eingerichtet sind, initialisieren wir category mit 0 und durchsuchen das Array categoryThreshold, bis entweder die Bruttoumsatzgrenze größer als der Bruttoumsatz ist oder bis es keine weiteren Gewerbekategorien mehr gibt . Beim Beenden die Schleife wird category in beiden Fällen anhand des Bruttoumsatzes die korrekte Gewerbekategorie zugewiesen. Im letzten Schritt wird category dazu verwendet, im Array licenseCost auf die zugehörige Konzessionsabgabe zu verweisen . Wie zuvor ist wieder die Anpassung erforderlich, um den Wertebereich der Gewerbekategorien von 1 bis 4 auf denjenigen des Arrays von 0 bis 3 abzubilden.

3.4 Nicht-skalare Arrays

Bislang haben wir ausschließlich Arrays mit einfachen Datentypen wie int oder double verwendet. Häufig müssen sich Programmierer jedoch mit Arrays auseinandersetzen, die aus zusammengesetzten Datentypen bestehen, nämlich aus Strukturen oder Objekten (struct oder class). Auch wenn die Verwendung zusammengesetzter Datentypen den Code naturgemäß etwas verkompliziert, muss dies unsere Überlegungen beim Umgang mit Arrays nicht unbedingt ebenfalls erschweren. Für gewöhnlich ist oft nur ein einziges struct- oder class-Attribut betroffen und wir können die anderen Teile der Datenstruktur ignorieren. Gelegentlich machen zusammengesetzte Datentypen es jedoch erforderlich, den Lösungsansatz zu ändern.

Betrachten wir beispielsweise die Aufgabe, in einer Reihe von Schulnoten die höchste zu finden. Statt eines int-Arrays kommt ein Array einer Datenstruktur zum Einsatz, die jeweils einen Schülerdatensatz repräsentiert:

struct student {
    int grade;
    int studentID;
    string name;
};

Es ist beim Umgang mit Arrays sehr angenehm, dass es einfach ist, ein ganzes Array zum Testen mit Festwerten zu initialisieren, selbst wenn es sich um ein struct-Array handelt:

const int ARRAY_SIZE = 10;
student studentArray[ARRAY_SIZE] = {
    {87, 10001, "Fred"},
    {28, 10002, "Tom"},
    {100, 10003, "Alistair"},
    {78, 10004, "Sasha"},
    {84, 10005, "Erin"},
    {98, 10006, "Belinda"},
    {75, 10007, "Leslie"},
    {70, 10008, "Candy"},
    {81, 10009, "Aretha"},
    {68, 10010, "Veronica"}
};

Diese Deklaration bedeutet, dass das Element studentArray[0] die Werte 87, 10001 und „Fred” in den Variablen grade, studentID und name besitzt. Ähnliches gilt für die anderen neun Elemente des Arrays. Was den weiteren Code betrifft, so könnten wir einfach denjenigen vom Anfang dieses Kapitels kopieren und alle Ausdrücke der Form intArray[index] durch studentArray[index].grade ersetzen. Das ergibt dann Folgendes:

int highest = studentArray[0].grade;
for (int i = 1; i < ARRAY_SIZE; i++) {
    if (studentArray[i].grade > highest)
                        highest = studentArray[i].grade;
}

Nehmen wir nun an, dass wir aufgrund neuer Informationen den Namen des Schülers mit der besten Note, nicht die Note selbst, finden möchten. Dazu müssen weitere Änderungen vorgenommen werden. Wenn die Schleife durchlaufen ist, kennen wir nur die beste Note, und das erlaubt es uns nicht, den dazugehörigen Schüler zu ermitteln. Wir müssten das Array ein weiteres Mal durchforsten und nach der struct mit der passenden Variablen grade suchen, und das sieht nach unnützer, doppelter Arbeit aus. Um dies zu vermeiden, könnten wir entweder zusätzlich auch den Namen des Schülers nachverfolgen, der zum aktuellen Wert in highest gehört, oder statt des höchsten Werts dessen Position im Array speichern, ähnlich wie wir es früher schon beim Histogramm getan haben. Der letztere Ansatz ist der allgemeinste, denn das Nachverfolgen der Position erlaubt es uns, später alle Daten des fraglichen Schülers abzufragen:

int highPosition = 0;
for (int i = 1; i < ARRAY_SIZE; i++) {
    if (studentArray[i].grade > studentArray[highPosition].grade) {
    highPosition = i;
    }
}

Die Variable highPosition nimmt nun den Platz von highest ein. Da wir den höchsten Notenwert nicht direkt nachverfolgen, verwenden wir highPosition, um auf studentArray zu verweisen , wenn es an der Zeit ist, den höchsten Notenwert mit dem aktuellen zu vergleichen. Falls der an der aktuellen Position im Array befindliche Notenwert höher ist, wird highPosition die aktuelle Position der Verarbeitungsschleife zugewiesen . Ist die Schleife durchlaufen, können wir mittels studentArray[highPosition].name auf den Namen des Schülers mit der höchsten Note zugreifen und haben auch Zugriff auf alle anderen Daten dieses Schülers.

3.5 Mehrdimensionale Arrays

Bislang haben wir nur eindimensionale Arrays betrachtet, weil diese am gebräuchlichsten sind. Zweidimensionale Arrays sind ungewöhnlich, und Arrays mit drei oder mehr Dimensionen sind selten. Das liegt daran, dass die meisten Daten ihrer Natur nach eindimensional sind. Darüber hinaus können die meisten Daten, die an sich mehrdimensional sind, durch mehrere eindimensionale Arrays repräsentiert werden. Die Verwendung mehrdimensionaler Arrays steht dem Programmierer daher fast immer frei. Werfen Sie doch mal einen Blick auf die Daten zur Konzessionsabgabe in Tabelle 3.1. Das sind zweifelsohne mehrdimensionale Daten. Ernsthaft, sehen Sie es sich doch an – es ist ein Gitternetz! Dennoch habe ich diese mehrdimensionalen Daten im Code durch die beiden eindimensionalen Arrays categoryThreshold und licenseCost wiedergegeben. Stattdessen hätte ich auch ein zweidimensionales Array verwenden können:

const double licenseData[2][numberCategories] = {
    {0.0, 50000.0, 150000.0, 500000.0},
    {50.0, 200.0, 1000.0, 5000.0}
};

Es ist kaum möglich, irgendeinen Vorteil wahrzunehmen, der sich aus der Kombination der beiden eindimensionalen Arrays zu einem zweidimensionalen Array ergibt. Der Code wird nirgendwo vereinfacht, denn es gibt keinen Grund, sämtliche Daten in der Tabelle auf einen Schlag zu verarbeiten. Es fällt allerdings ins Auge, dass die Lesbarkeit und die einfache Benutzbarkeit der Daten in der Tabelle darunter leiden. In der ursprünglichen Version ist an den Bezeichnungen der Arrays erkennbar, welche Art von Daten diese speichern. Beim mehrdimensionalen Array müssen wir Programmierer uns merken, dass Ausdrücke der Form licenseData[0][] auf die Bruttoumsatzgrenzen der verschiedenen Gewerbekategorien und solche der Form licenseData[1][] auf die Höhe der Konzessionsabgabe verweisen.

Manchmal ist es dennoch sinnvoll, ein mehrdimensionales Array zu verwenden. Angenommen, wir verarbeiten die monatlichen Verkaufsdaten dreier Handelsvertreter und eine der Aufgaben ist es, den höchsten Verkaufsumsatz eines jeden Vertreters zu finden. Befinden sich sämtliche Daten in einem Array mit 3x12 Elementen, kann das ganze Array mittels zweier verschachtelter Schleifen auf einmal verarbeitet werden:

const int NUM_AGENTS = 3;
const int NUM_MONTHS = 12;
int sales[NUM_AGENTS][NUM_MONTHS] = {
    {1856, 498, 30924, 87478, 328, 2653, 387, 3754, 387587, 2873, 276, 32},
    {5865, 5456, 3983, 6464, 9957, 4785, 3875, 3838, 4959, 1122, 7766, 2534},
    {23, 55, 67, 99, 265, 376, 232, 223, 4546, 564, 4544, 3434}
};
int highestSales = sales[0][0];
for (int agent = 0; agent < NUM_AGENTS; agent++) {
    for (int month = 0; month < NUM_MONTHS; month++) {
        if (sales[agent][month] > highestSales)
           highestSales = sales[agent][month];
    }
}

Auch wenn es sich hier um eine direkte Anpassung des Codes zum Aufspüren des größten Wertes handelt, sind doch einige Kniffe zu beachten. Bei der Initialisierung des zweidimensionalen Arrays wird dieses anhand der Vertreter aufgebaut, also als 3 Gruppen mit jeweils 12 Werten, nicht als 12 Gruppen à 3 Einträge . Wie Sie bei der nächsten Aufgabenstellung sehen werden, kann diese Entscheidung durchaus Konsequenzen haben. highestSales wird wie gewohnt mit dem ersten Element des Arrays initialisiert . Vielleicht ist Ihnen aufgefallen, dass beim ersten Durchlauf der verschachtelten Schleifen beide Schleifenvariablen 0 sind, und wir vergleichen den Startwert von highestSales mit sich selbst. Das hat zwar keine Auswirkung auf das Resultat, aber manchmal versuchen Programmierneulinge, diese winzige Ineffizienz zu umgehen, indem sie der inneren Schleife eine zweite if-Anweisung hinzufügen:

if (agent != 0 || month != 0)
    if (sales[agent][month] > highestSales)
        highestSales = sales[agent][month];

Tatsächlich ist dies erheblich weniger effizient als die vorige Version, weil dadurch 50 zusätzliche Vergleiche durchgeführt werden müssen, um nur einen einzigen Vergleich zu vermeiden.

Beachten Sie ebenfalls, dass ich hier aussagekräftige Bezeichnungen für die Schleifenvariablen gewählt habe: agent für die äußere und month für die innere Schleife . In einer einzelnen Schleife, die ein eindimensionales Array verarbeitet, ist mit einem beschreibenden Namen kaum etwas gewonnen. Bei einer doppelten Schleife hingegen erlauben es mir die aussagekräftigen Bezeichnungen, die verschiedenen Dimensionen und Indizes auseinanderzuhalten, weil erkennbar ist, dass ich agent für diejenige Dimension verwende, für die ich bei der Deklaration des Arrays NUM_AGENTS verwendet habe.

Selbst bei multidimensionalen Arrays ist es manchmal besser, nur jeweils eine der Dimensionen zu benutzen. Angenommen, Sie verwenden das sales-Array des oben stehenden Codes und suchen den höchsten durchschnittlichen monatlichen Verkaufsumsatz. Wir könnten dies wie zuvor mit einer doppelten Schleife erledigen, aber der Code wäre verständlicher und leichter zu schreiben, wenn wir das gesamte Array als drei getrennte Arrays behandeln und diese separat verarbeiten. Erinnern Sie sich an den Code, den wir zum Berechnen des Durchschnittswerts eines Arrays von Ganzzahlen verwendet haben? Machen wir daraus eine Funktion:

double arrayAverage(int intArray[], int ARRAY_SIZE) {
    double sum = 0;
    for (int i = 0; i < ARRAY_SIZE; i++) {
        sum += intArray[i];
    }
   double average = sum / ARRAY_SIZE;
   return average;
}

Durch diese Funktion können wir den Code zur Suche des größten Wertes so modifizieren, dass er den Vertreter mit dem höchsten durchschnittlichen monatlichen Verkaufsumsatz liefert:

double highestAverage = arrayAverage(sales[0], 12);
for (int agent = 1; agent < NUM_AGENTS; agent++) {
    double agentAverage = arrayAverage(sales[agent], 12);
    if (agentAverage > highestAverage)
        highestAverage = agentAverage;
}
cout << "Höchster monatlicher Durchschnitt: "
     << highestAverage << "\n";

Der entscheidende Gedanke wird hier bei den beiden Aufrufen von arrayAverage deutlich. Der erste Parameter dieser Funktion ist ein eindimensionales int-Array. Beim ersten Aufruf übergeben wir als erstes Argument sales[0] und beim zweiten sales[agent] . In beiden Fällen geben wir also einen Index für die erste Dimension des zweidimensionalen Arrays sales an, nicht jedoch für die zweite Dimension. Aufgrund des direkten Zusammenhangs zwischen Arrays und deren Speicheradresse in C++ bezeichnet dieser Verweis die Adresse des ersten Elements der angegebenen Reihe, die dann von unserer Funktion als Startadresse eines eindimensionalen Arrays verwendet wird, das nur aus dieser einen Reihe besteht.

Wenn das verwirrend klingt, betrachten Sie die Deklaration des sales-Arrays und insbesondere dessen Initialisierung. Die einzelnen Werte befinden sich bei der Initialisierung in derselben Reihenfolge, in der sie beim Ausführen des Programms im Arbeitsspeicher abgelegt werden. Zuerst kommt also sales[0][0], 1856, gefolgt von sales[0][1], 498, usw. bis zum letzten Monatswert des ersten Vertreters, sales[0][11], 32. Danach folgen die Werte des zweiten Vertreters, angefangen mit sales[1][0], 5865. Das Array besteht zwar logisch aus 3 Reihen und 12 Spalten, wird im Arbeitsspeicher jedoch als zusammenhängende Reihe von 36 Werten abgelegt.

Hier muss betont werden, dass diese Vorgehensweise aufgrund der Reihenfolge gelingt, in der die Daten im Array angeordnet sind. Wären die Werte nicht nach Vertretern, sondern nach Monaten angeordnet, würde es nicht funktionieren. Die gute Nachricht lautet, dass es leicht überprüfbar ist, ob das Array korrekt aufgebaut ist – werfen Sie einfach einen Blick auf die Initialisierung. Die Daten, die Sie einzeln verarbeiten möchten, müssen dem Array bei der Initialisierung in zusammenhängender Weise übergeben werden, anderenfalls sind die Daten nicht richtig organisiert.

Schließlich ist bei diesem Code noch die Verwendung der temporären Variablen agentAverage zu beachten. Da der durchschnittliche monatliche Verkaufsumsatz des aktuellen Vertreters potenziell zwei Mal abgefragt wird (zunächst in der Bedingung der if-Anweisung und ein weiteres Mal in der Zuweisung innerhalb der if-Anweisung), verhindert die temporäre Variable, dass die Funktion arrayAverage doppelt mit den Werten des aktuellen Vertreters aufgerufen wird.

Dieses Verfahren, ein multidimensionales Array als ein Array von Arrays zu behandeln, folgt unmittelbar aus dem Grundprinzip, Aufgaben in einfachere Komponenten aufzuteilen, und erlaubt es im Allgemeinen, Problemstellungen mit multidimensionalen Arrays sehr viel einfacher in Begriffe zu kleiden. Dennoch meinen Sie vielleicht, dass es etwas knifflig erscheint, dieses Verfahren einzusetzen, und wenn es Ihnen wie den meisten neuen C++-Programmieren geht, sind Sie vermutlich etwas misstrauisch gegenüber Adressen und Adressarithmetik, die „hinter den Kulissen” stattfindet. Ich glaube, das beste Mittel gegen diesen Argwohn ist es, eine noch strengere Trennung der Dimensionen vorzunehmen, indem man eines der Arrays innerhalb einer Datenstruktur oder einer Klasse deklariert. Wenn wir agentStruct wie folgt anlegen:

struct agentStruct {
    int monthlySales[12];
};

könnten wir auch in Erwägung ziehen, weitere Daten hinzuzufügen, wie etwa eine Vertreter-ID. Für die Vereinfachung unserer Überlegungen ist dies aber schon ausreichend. Nachdem die struct erstellt ist, legen wir statt eines zweidimensionalen sales-Arrays ein eindimensionales Array von Vertretern an:

agentStruct agents[3];

Wenn wir nun die Funktion arrayAverage aufrufen, brauchen wir keinen C++-spezifischen Trick mehr zu verwenden, sondern übergeben einfach ein eindimensionales Array, wie beispielsweise:

int highestAverage = arrayAverage(agents[1].monthlySales, 12);

3.6 Wann werden Arrays verwendet?

Ein Array ist eigentlich nur ein Werkzeug. Wie bei jedem Werkzeug ist beim Lernen, wie man ein Array verwendet, die Frage wichtig, wann man es verwendet – und wann besser nicht. Die bislang erläuterten Beispielaufgaben setzten bereits in der Beschreibung die Verwendung von Arrays voraus. In den meisten Fällen wird dieses Detail jedoch nicht weiter erwähnt und wir müssen selbst erwägen, ob Arrays Verwendung finden sollten. Die häufigsten Situationen, in denen diese Entscheidung zu treffen ist, sind solche, in denen wir eine Datensammlung erhalten, aber uns niemand sagt, wie sich diese Daten intern speichern lassen. Bei der Aufgabe, den Modalwert zu finden, hätte es beispielsweise statt Schreiben Sie Code, der ein Array mit Umfragedaten auswertet ... auch Schreiben Sie Code, der eine Erhebung von Umfragedaten auswertet ... heißen können. Die Frage, ob die Verwendung eines Arrays sinnvoll wäre oder nicht, müssten wir dann selbst beantworten. Wie trifft man diese Entscheidung?

Denken Sie daran, dass wir die Größe eines Arrays nach dem Erstellen nicht mehr ändern können. Wenn uns der Speicherplatz ausgeht, versagt unser Programm. Die erste Überlegung ist also, ob wir an der Stelle im Code, an der eine zusammengesetzte Datenstruktur erforderlich ist, bereits wissen, wie viele Werte wir speichern müssen, oder zumindest eine verlässliche Abschätzung der maximalen Anzahl dieser Werte vornehmen können. Das heißt nicht, dass wir die Größe des Arrays bereits beim Schreiben des Programms kennen müssen. C++ erlaubt es, wie die meisten anderen Programmiersprachen auch, ein Array zu erstellen, dessen Größe erst zur Laufzeit des Programms festgelegt wird. Nehmen wir an, die Aufgabe zur Berechnung des Modalwerts wird so abgeändert, dass die Anzahl der Antworten bei der Umfrage vorher unbekannt ist und dem Programm erst durch eine Benutzereingabe bereitgestellt wird. Wir könnten dann zur Laufzeit ein Array zum Speichern der Umfragedaten deklarieren:

int ARRAY_SIZE;
cout << "Anzahl der Antworten bei der Umfrage: ";
cin >> ARRAY_SIZE;
int *surveyData = new int[ARRAY_SIZE];
for(int i = 0; i < ARRAY_SIZE; i++) {
   cout << "Umfrageantwort " << i + 1 << ": ";
   cin >> surveyData[i];
}

Das Array wird unter Verwendung der Zeigernotation deklariert und durch Aufruf des new-Operators initialisiert . Dank der Wandelbarkeit von Zeigern und Arrays in C++ kann auf die Elemente mittels Array-Notation zugegriffen werden , obwohl surveyData als Zeiger deklariert ist. Beachten Sie, dass der Speicherplatz für das Array dynamisch bereitgestellt wird und daher am Ende des Programms, wenn das Array nicht mehr benötigt wird, freigegeben werden muss:

delete[] surveyData;

Statt des üblichen delete-Operators wird für Arrays der delete[]-Operator verwendet. Zwar ergibt sich bei einem int-Array hier kein Unterschied, wenn Sie jedoch ein Array von Objekten anlegen, stellt der delete[]-Operator sicher, dass die einzelnen Objekte des Arrays gelöscht werden, bevor das Array selbst gelöscht wird. Sie sollten sich daher angewöhnen, bei dynamisch allozierten Arrays immer delete[] zu verwenden.

Für das Aufräumen dynamisch allozierten Speichers verantwortlich zu sein, ist der Fluch eines jeden C++-Programmierers, aber wenn Sie die Sprache verwenden, gehört dies einfach dazu. Angehende Programmierer drücken sich oft vor dieser Verantwortung, weil ihre Programme so klein sind und so kurzzeitig ausgeführt werden, dass sie die schädlichen Folgen von Speicherlecks (vom Programm nicht mehr verwendeter Arbeitsspeicher, der aber nicht wieder freigegeben wird und somit dem übrigen System nicht zur Verfügung steht) nicht wahrnehmen. Gewöhnen Sie sich diese Unart gar nicht erst an.

Beachten Sie, dass wir das dynamische Array nur verwenden können, weil der Benutzer die Anzahl der Antworten bei der Umfrage vorab eingibt. Denken Sie beispielsweise an eine andere Variante, bei der ein Benutzer mit der Eingabe von Werten beginnt, ohne vorher die Anzahl der Antworten einzugeben, und stattdessen durch Eingabe des Werts -1 signalisiert, dass keine weiteren Werte folgen (Beenden der Eingabe durch ein Hinweiszeichen). Könnten wir dennoch ein Array verwenden, um diese Aufgabe zu lösen?

Wir befinden uns hier in einer Grauzone. Wir könnten weiterhin ein Array verwenden, wenn garantiert ist, dass eine maximale Anzahl von Antworten nicht überschritten wird. In diesem Fall könnten wir ein Array dieser Größe deklarieren und davon ausgehen, damit auf der sicheren Seite zu sein. Langfristig gesehen gibt es hier jedoch Bedenken. Was passiert, wenn die Zahl der Antworten künftig doch irgendwann wächst? Und was geschieht, wenn das Programm mit einem anderen Umfrageinstitut verwendet werden soll? Oder allgemeiner gefragt: Warum sollte man ein Programm mit einer solchen Einschränkung schreiben, wenn diese vermeidbar ist?

Es ist also vernünftiger, eine Datenstruktur ohne Größenbeschränkung zu verwenden. Wie bereits kurz erwähnt, verhält sich die Vektorklasse der C++-STL (Standard Template Library) wie ein Array, das bei Bedarf wächst. Nach Deklaration und Initialisierung kann ein Vektor auf genau dieselbe Weise wie ein Array verarbeitet werden. Wir können dem Element eines Vektors Werte zuweisen oder mittels der standardmäßigen Array-Notation Werte abfragen. Wenn der Vektor die bei der Initialisierung zugewiesene Größe erreicht hat, können mit der Methode push_back weitere Elemente hinzugefügt werden. Die Lösung der modifizierten Aufgabenstellung mit einem Vektor sieht folgendermaßen aus:

vector<int> surveyData;
surveyData.reserve(30);
int surveyResponse;
cout << "Nächste Antwort oder -1 zum Beenden eingeben: ";
cin >> surveyResponse;
while (surveyResponse != -1) {
     surveyData.push_back(surveyResponse);
     cout << " Nächste Antwort oder -1 zum Beenden eingeben: ";
     cin >> surveyResponse;
}
int vectorSize = surveyData.size();
const int MAX_RESPONSE = 10;
int histogram[MAX_RESPONSE];
for (int i = 0; i < MAX_RESPONSE; i++) {
    histogram[i] = 0;
}
for (int i = 0; i < vectorSize; i++) {
    histogram[surveyData[i] - 1]++;
}
int mostFrequent = 0;
for (int i = 1; i < MAX_RESPONSE; i++) {
    if (histogram[i] > histogram[mostFrequent]) mostFrequent = i;
}
mostFrequent++;

In diesem Code deklarieren wir zunächst den Vektor und reservieren danach Speicherplatz für 30 Antworten . Dieser zweite Schritt ist nicht unbedingt erforderlich, jedoch verhindert die Reservierung von etwas Speicherkapazität, deren Aufnahmevermögen die wahrscheinliche Anzahl der Elemente übertrifft, dass der Vektor ständig seine Größe ändert, wenn wir ihm Werte hinzufügen. Wir lesen den ersten Wert ein , bevor die Schleife zur Dateneingabe erreicht wird, ein Verfahren, das wir schon im letzten Kapitel dazu verwendet haben, alle Eingaben vor der Verarbeitung zu überprüfen. In diesem Fall soll verhindert werden, dass das Hinweiszeichen -1 dem Vektor hinzugefügt wird. Die Umfrageergebnisse werden dem Vektor mittels der push_back-Methode hinzugefügt . Wenn die Schleife zur Dateneingabe beendet ist, fragen wir mit der size-Methode die Größe des Vektors ab . Wir hätten die Anzahl der Elemente in der Eingabeschleife auch selbst zählen können, aber da der Vektor seine Größe selbst überwacht, wird hiermit unnötige Arbeit vermieden. Der verbleibende Code entspricht der Version mit Array und vorgegebener Zahl der Antworten. Wir haben lediglich die Variablennamen geändert.

Bei all diesen Überlegungen zu Vektoren haben wir jedoch einen wichtigen Punkt übersehen. Wenn wir die Daten in Form von Benutzereingaben einlesen, statt ein Array oder eine andere zusammengesetzte Datenstruktur zu verwenden, benötigen wir für die Umfrageergebnisse eigentlich gar kein Array, sondern nur für das Histogramm. Stattdessen können wir die Umfragewerte beim Einlesen verarbeiten. Eine Datenstruktur benötigen wir nur, wenn alle Werte vor der Verarbeitung eingelesen oder mehrmals verarbeitet werden müssen. In diesem Fall ist weder das eine noch das andere erforderlich:

const int MAX_RESPONSE = 10;
int histogram[MAX_RESPONSE];
for (int i = 0; i < MAX_RESPONSE; i++) {
    histogram[i] = 0;
}
int surveyResponse;
cout << "Nächste Antwort oder -1 zum Beenden eingeben: ";
cin >> surveyResponse;
while (surveyResponse != -1) {
    histogram[surveyResponse - 1]++;
    cout << "Nächste Antwort oder -1 zum Beenden eingeben:";
    cin >> surveyResponse;
}
int mostFrequent = 0;
for (int i = 1; i < MAX_RESPONSE; i++) {
    if (histogram[i] > histogram[mostFrequent]) mostFrequent = i;
}
mostFrequent++;

Obwohl es – mit den vorhergehenden Versionen als Leitfaden – ziemlich einfach war, diesen Code zu schreiben, wäre es noch leichter gewesen, die Benutzereingaben in ein Array einzulesen und die vorige Verarbeitungsschleife wortwörtlich zu übernehmen. Der Vorteil des Ansatzes, die Werte während der Eingabe zu verarbeiten, ist dessen Effizienz. So wird es vermieden, unnötigerweise sämtliche Umfrageergebnisse abzuspeichern, wenn wir nur jeweils eine der Antworten speichern müssen. Die Lösung, die einen Vektor verwendet, war ineffizient bezüglich des Speicherplatzes: Sie benötigt mehr Speicher als notwendig, ohne einen entsprechenden Vorteil zu bieten. Das Einlesen aller Umfrageergebnisse in den Vektor erfordert außerdem eine eigene Schleife, zusätzlich zu den beiden Schleifen zur Verarbeitung der Umfrageergebnisse und zum Auffinden des höchsten Wertes im Histogramm. Das heißt, die Vektorversion muss mehr Arbeit erledigen als die oben stehende Version. Daher ist die Vektorversion auch ineffizient bezüglich der Zeit: Sie erledigt mehr Arbeit als erforderlich, ohne einen entsprechenden Vorteil zu bieten. In manchen Fällen bringen andere Lösungen zum Ausgleich andere Vorteile mit sich, und die Programmierer müssen sich zwischen Speichereffizienz und Geschwindigkeit entscheiden. Im vorliegenden Fall jedenfalls wird das Programm durch die Verwendung des Vektors rundherum ineffizient.

In diesem Buch werden wir der Jagd nach Ineffizienz keinen großen Raum geben. Manchmal kommt man als Programmierer nicht umhin, ein Performance Tuning durchzuführen. Dabei handelt es sich um eine systematische Analyse und Verbesserung der Geschwindigkeit und effizienten Speichernutzung eines Programms. Das Tuning eines Programms ähnelt dem Tuning eines Rennwagens: eine anspruchsvolle Aufgabe, bei der kleine Änderungen große Auswirkungen haben können und bei der Expertenwissen über die Mechanismen „unter der Haube” gefragt ist. Auch wenn wir weder Zeit noch Lust haben und auch nicht über das erforderliche Wissen verfügen, um ein vollständiges Performance Tuning durchzuführen, sollten dennoch Entscheidungen vermieden werden, die zu krasser Ineffizienz führen. Die unnötige Verwendung eines Vektors oder Arrays entspricht mitnichten einem Verbrennungsmotor, dessen Kraftstoff-Luft-Gemisch zu mager ist. Es ist eher damit vergleichbar, mit einem Sattelschlepper zum Strand zu fahren, obwohl alles, was Sie mitnehmen, auch in einen Bollerwagen gepasst hätte.

Wenn feststeht, dass die Daten mehrmals verarbeitet werden müssen, und wir über die maximale Größe der Datensätze Bescheid wissen, ist das letzte Kriterium für die Entscheidung, ein Array zu verwenden, der wahlfreie Zugriff. Wir werden später noch alternative Datenstrukturen wie Listen untersuchen, die ebenso wie Vektoren bei Bedarf wachsen können, auf die, im Gegensatz zu Vektoren und Elementen eines Arrays, aber nur sequenziell zugegriffen werden kann. Das heißt, dass beim Zugriff auf das zehnte Element einer Liste zunächst die ersten neun durchlaufen werden müssen. Wahlfreier Zugriff hingegen bedeutet, dass wir jederzeit auf alle Elemente eines Arrays oder Vektors zugreifen können. Die letzte Regel, die es zu beachten gilt, lautet daher, dass wir ein Array verwenden sollten, wenn wahlfreier Zugriff benötigt wird.

Vielleicht ist Ihnen aufgefallen, dass viele der Programme in diesem Kapitel dem letzten Kriterium nicht genügen. Wir greifen sequenziell auf die Daten zu, nicht wahlfrei, und dennoch verwenden wir Arrays. Es gibt für all diese Regeln eine große Ausnahme, die eine Frage des gesunden Menschenverstands ist. Wenn es sich um kleine Arrays handelt, besitzt keines der vorgetragenen Bedenken nennenswerte Bedeutung. Was genau hier mit „klein” gemeint ist, hängt von der eingesetzten Plattform oder dem verwendeten Programm ab. Wenn Ihr Programm eine Datenstruktur benötigt, die vielleicht aus nur einem, maximal aber aus 10 Objekten besteht, von denen jedes 10 Bytes belegt, dann ist der entscheidende Punkt, dass Sie erwägen müssen, ob die potenzielle Verschwendung von 90 Bytes Arbeitsspeicher beim Allozieren eines Arrays maximaler Größe es wert ist, nach einer anderen Lösung zu suchen. Verwenden Sie Arrays mit Bedacht, aber lassen Sie den Perfektionismus nicht zum Feind des Guten werden.

3.7 Übungen

Ich fordere Sie wie immer auf, möglichst viele der Übungen auszuprobieren.

3.1

Sind Sie enttäuscht, dass wir uns nicht mehr mit Sortieren beschäftigt haben? Ihnen kann geholfen werden. Schreiben Sie ein Programm, das die Funktion qsort zum Sortieren der struct für die Schülerdatensätze nutzt, wenn Sie mit qsort gut umgehen können wollen. Sortieren Sie zunächst nach der Note und versuchen Sie es dann mit der Schüler-ID.

3.2

Schreiben Sie den Code, der den Vertreter mit den besten durchschnittlichen monatlichen Verkaufsumsätzen ermittelt, so um, dass er den Vertreter findet, dessen Medianwert der monatlichen Verkaufsumsätze am höchsten ist. Wie bereits kurz erwähnt, ist der Medianwert derjenige Wert in der Mitte, über und unter dem sich jeweils die Hälfte der anderen Werte befinden. Ist die Anzahl der Werte gerade, ist der Medianwert der einfache Durchschnittswert der beiden mittleren Werte. In der Zahlenfolge 10, 6, 2, 14, 7, 9 beispielsweise sind 7 und 9 die beiden mittleren Werte. Der Durchschnittswert von 7 und 9 ist 8, also ist der Medianwert 8.

3.3

Schreiben Sie eine bool-Funktion, der ein Array und die Anzahl der Elemente des Arrays übergeben werden und die überprüft, ob die Daten des Arrays sortiert sind. Das Array soll dabei nur einmal durchlaufen werden!

3.4

Eine Variante des Arrays mit const-Werten: Schreiben Sie ein Programm, das Vertauschungs-Chiffre-Rätsel erstellt. Bei solch einem Rätsel bestehen alle Botschaften aus Großbuchstaben und Satzzeichen. Die ursprüngliche Botschaft wird als Klartext bezeichnet. Die Chiffre wird durch Vertauschen der einzelnen Buchstaben durch andere Buchstaben erstellt (beispielsweise könnte jedes C zu einem X werden). Legen Sie bei dieser Aufgabe für die Chiffre ein const-Array mit 26 Elementen des Datentyps char an und sorgen Sie dafür, dass Ihr Programm Klartext einliest und die Chiffre ausgibt.

3.5

Sorgen Sie dafür, dass das vorhergehende Programm die Chiffre zurück in Klartext konvertiert, um die Ver- und Entschlüsselung zu überprüfen.

3.6

Um die Chiffre-Aufgabe weiter zu erschweren: Erzeugen Sie statt der fest zugewiesenen Chiffre im const-Array eine zufällige Chiffre. Dabei wird jedes Element des Arrays mit einem zufälligen Zeichen belegt. Beachten Sie aber, dass ein Zeichen nicht durch sich selbst ersetzt werden darf. Der erste Buchstabe darf also kein A sein, und Sie dürfen auch keinen Buchstaben doppelt verwenden. Wenn also der erste Buchstabe ein S ist, darf keinem der anderen Elemente ein S zugewiesen werden.

3.7

Schreiben Sie ein Programm, dem ein int-Array übergeben wird und das den Modalwert berechnet, also die Zahl, die im Array am häufigsten vorkommt.

3.8

Schreiben Sie ein Programm, das ein Array von Schüler-Objekten verarbeitet und die Notenquartile errechnet, also die Noten, die erreicht werden müssen, um mindesten genauso gut wie oder besser als 25%, 50% bzw. 75% der Schüler abzuschneiden.

3.9

Betrachten Sie die folgende Modifikation des sales-Arrays: Da die Handelsvertreter im Laufe des Jahres wechseln, werden die Monate vor der Anstellung eines Vertreters bzw. nach dessen Ausscheiden mit -1 gekennzeichnet. Schreiben Sie den Code zur Berechnung des höchsten durchschnittlichen monatlichen Verkaufsumsatzes bzw. des Medianwertes der monatlichen Verkaufsumsätze um, damit dies berücksichtigt wird.

Kapitel 4: Zeiger und dynamische Speicherverwaltung

In diesem Kapitel werden wir uns damit beschäftigen, Zeiger und dynamische Speicherverwaltung zur Problemlösung einzusetzen. Das erlaubt es uns, flexible Programme zu schreiben, die mit unbekannten Eingabewerten umgehen können. Machen wir uns nichts vor: Die Verwendung von Zeigern und die dynamische Verwaltung des Arbeitsspeichers repräsentieren das Ende der Fahnenstange, was die Programmierung betrifft. Wenn Sie Programme schreiben können, die nebenbei Speicherbereiche reservieren, diese mit Datenstrukturen verknüpfen und zum Schluss alles wieder freigeben und aufräumen, haben Sie es geschafft. Sie können nicht nur ein wenig Code eingeben, sondern sind ein richtiger Programmierer.

Da der Einsatz von Zeigern kompliziert ist und viele populäre Programmiersprachen (wie etwa Java) weitestgehend auf die Verwendung von Zeigern verzichten, versuchen manche Grünschnäbel, sich selbst davon zu überzeugen, dass dieses Thema ignoriert werden kann. Dies ist jedoch ein grober Fehler. In komplexeren Programmen sind Zeiger und Zugriff auf den Arbeitsspeicher mittels Zeiger unentbehrlich, selbst wenn eine Hochsprache dies scheinbar durch eigene Zugriffsmethoden verbirgt. Sie kommen also nicht darum herum, sich mit Zeigern auseinanderzusetzen, wenn Sie tatsächlich wie ein Programmierer denken wollen.

Bevor wir uns mit den eigentlichen Aufgabenstellungen befassen, untersuchen wir die Funktionsweise von Zeigern sorgfältig, und zwar nicht nur oberflächlich, sondern wir sehen uns auch an, was „hinter den Kulissen” vor sich geht. Diese Betrachtungen bieten zwei Vorteile. Zum einen wird das erworbene Wissen es Ihnen ermöglichen, Zeiger möglichst effizient einzusetzen. Zum anderen können Sie Zeiger mit Zuversicht verwenden, sobald die damit verbundenen scheinbaren Rätsel erst aufgelöst sind.

4.1 Zeiger-Grundlagen

Sie sollten, wie auch bei den in den vorhergehenden Kapiteln beschriebenen Themen, über elementare Kenntnisse verfügen. Um sicherzustellen, dass dies der Fall ist, folgt eine kurze Übersicht über die Verwendung von Zeigern.

In C++ werden Zeiger durch ein Sternchen (*) gekennzeichnet. Je nach Kontext gibt das Sternchen an, dass ein Zeiger deklariert wird oder dass der Speicherinhalt gemeint ist, auf den der Zeiger verweist, nicht der Zeiger selbst. Bei der Deklaration eines Zeigers steht das Sternchen zwischen dem Datentyp und der Bezeichnung:

int * intPointer;

Hiermit wird die Variable intPointer als Zeiger auf eine Ganzzahl deklariert. Beachten Sie, dass das Sternchen zur Bezeichnung der Variablen gehört, nicht zum Datentyp. In der folgenden Deklaration ist variable1 ein Zeiger auf eine Ganzzahl, variable2 jedoch nur eine einfache Ganzzahl:

int * variable1, variable2;

Wir können einem Zeiger auch direkt den Wert eines anderen Zeigers zuweisen:

intPointer = variable1;

Am wichtigsten dürfte es sein, dass wir zur Laufzeit Speicher allozieren können, auf den nur mittels eines Zeigers zugegriffen werden kann. Dazu dient der new-Operator:

double * doublePointer = new double;

Der Zugriff auf den Speicherinhalt, auf den der Zeiger verweist, wird als Dereferenzierung bezeichnet und wird mittels eines Sternchens auf der linken Seite der Zeigerbezeichnung erreicht. Hier handelt es sich wieder um dieselbe Position, die auch bei einer Zeigerdeklaration verwendet wird. Die unterschiedliche Bedeutung ist aus dem Zusammenhang ersichtlich. Ein Beispiel:

*doublePointer = 35.4;
double localDouble = *doublePointer

Zunächst weisen wir dem weiter oben allozierten doublePointer einen Wert zu und kopieren dann den an dieser Speicheradresse befindlichen Wert in die Variable localDouble .

Um den mit new reservierten Speicherbereich wieder freizugeben, sobald wir diesen nicht mehr benötigen, verwenden wir das Schlüsselwort delete:

delete doublePointer;

Die Funktionsweise dieses Vorgangs wird im Abschnitt 4.4 ausführlich beschrieben.

4.2 Vorteile von Zeigern

Mit Zeigern steht uns eine Leistungsfähigkeit zur Verfügung, die es bei statischer Speicherreservierung nicht gibt. Darüber hinaus bieten Zeiger ganz neue Möglichkeiten bei der effizienten Verwendung von Arbeitsspeicher. Die drei wichtigsten Vorteile beim Einsatz von Zeigern sind:

Festlegung der Größe von Datenstrukturen zur Laufzeit

Größenänderung von Datenstrukturen

Gemeinsame Speichernutzung

Sehen wir uns diese drei Punkte etwas genauer an.

4.2.1 Festlegung der Größe von Datenstrukturen zur Laufzeit

Durch die Verwendung von Zeigern ist es möglich, die Größe eines Arrays erst zur Laufzeit festzulegen, anstatt die Größe von vornherein und noch vor dem Kompilieren des Programms zu definieren. Dadurch wird es uns erspart, zwischen potenziellem Speichermangel im Array und maximal erforderlicher Größe des Arrays (die in den meisten Fällen überhaupt nicht benötigt wird und daher Arbeitsspeicher verschwendet) wählen zu müssen. Einer Festlegung der Größe zur Laufzeit sind Sie im vorhergehenden Kapitel im Abschnitt „Wann werden Arrays verwendet?” bereits begegnet. Wir werden dieses Konzept im Abschnitt 4.5.1 später in diesem Kapitel erneut aufgreifen.

4.2.2 Größenänderung von Datenstrukturen

Mithilfe von Zeigern lassen sich Datenstrukturen erstellen, die ganz nach Bedarf zur Laufzeit wachsen oder schrumpfen können. Die einfachste dieser Datenstrukturen ist die verkettete Liste, die Ihnen vermutlich schon begegnet ist. Zwar kann auf die Daten in einer verketteten Liste nur sequenziell zugegriffen werden, jedoch verwendet diese immer nur so viele Speicherplätze, wie es Elemente gibt, sodass kein Speicherplatz verschwendet wird. Wie Sie noch sehen werden, gibt es andere, ausgefeiltere zeigerbasierte Datenstrukturen, die Ordnungsmöglichkeiten und Formen besitzen, mit denen sich die Beziehungen zwischen den zugrunde liegenden Daten besser wiedergeben lassen als mit Arrays. Auch wenn ein Array einen vollständigen wahlfreien Zugriff bietet, den keine zeigerbasierte Datenstruktur liefern kann, kann die Abfrage von Werten (das Auffinden des Elements in der Datenstruktur, das einem bestimmten Suchkriterium entspricht) bei zeigerbasierten Datenstrukturen sehr viel schneller sein. Wir werden hieraus später in diesem Kapitel noch Nutzen ziehen, wenn wir eine Datenstruktur für Schülerdatensätze erstellen, die bei Bedarf wächst.

4.2.3 Gemeinsame Speichernutzung

Durch Zeiger kann die Effizienz von Programmen gesteigert werden, indem Speicherblöcke gemeinsam genutzt werden. Beim Aufruf einer Funktion kann beispielsweise statt der Kopie eines Speicherblocks ein Zeiger auf diesen Block übergeben werden, also eine Referenz auf diesen Speicherblock. Die Verwendung eines solchen Referenzparameters ist Ihnen höchstwahrscheinlich bekannt. In der formalen Parameterliste wird zwischen Datentyp und Variablenname ein Et-Zeichen (&) eingefügt:

void refParamFunction (int & x) {
     x = 10;
}
int number = 5;
refParamFunction(number);
cout << number << "\n";

Hinweis

Die Leerzeichen vor und nach dem Et-Zeichen sind nicht erforderlich; ich habe diese nur aus ästhetischen Gründen eingefügt. Im Code anderer Entwickler werden Sie möglicherweise auch int& x, int &x oder sogar int&x antreffen.

Bei diesem Code ist der formale Parameter x keine Kopie des Arguments number , sondern eine Referenz auf die Speicheradresse, an der number gespeichert ist. Wenn nun der Wert von x geändert wird , ändert das auch den Inhalt des Speicherplatzes für number und die Ausgabe am Ende des Codeabschnitts lautet 10 . Wie dieses Beispiel zeigt, können Referenzparameter als Mechanismus verwendet werden, um Werte aus einer Funktion „herauszuholen”. Allgemein ausgedrückt erlauben es Referenzparameter, dass die aufgerufene und die aufrufende Funktion denselben Speicher gemeinsam nutzen und dadurch den Speicherverwaltungsaufwand verringern. Wenn eine als Parameter übergebene Variable ein Kilobyte Arbeitsspeicher belegt und als Referenzparameter übergeben wird, muss nur ein 32- oder 64-Bit-Zeiger statt des Kilobytes kopiert werden. Wenn wir einen Referenzparameter aus Performanzgründen verwenden, ohne zu beabsichtigen, dessen Wert zu ändern, können wir dies durch Verwendung des Schlüsselworts const signalisieren:

int anotherFunction(const int & x);

Wenn der Deklaration des Referenzparameters x das Schlüsselwort const vorangestellt ist, erhält die Funktion anotherFunction zwar eine Referenz auf das im Funktionsaufruf übergebene Argument, darf dessen Wert aber nicht ändern, ebenso wenig wie alle anderen const-Parameter.

Im Allgemeinen können Zeiger auf diese Weise verwendet werden, um anderen Programmteilen, oder anderen Datenstrukturen innerhalb des Programms, Zugriff auf Daten zu gewähren, ohne diese kopieren zu müssen.

4.3 Wann werden Zeiger verwendet?

Wie schon anhand von Arrays erläutert, können auch Zeiger potenziell Nachteile mitbringen und sollten nur verwendet werden, wenn es angebracht ist. Woran können wir erkennen, ob der Einsatz von Zeigern angemessen ist? Nachdem gerade die Vorteile von Zeigern aufgelistet wurden, könnten wir behaupten, dass Zeiger nur dann verwendet werden sollten, wenn wenigstens einer dieser Vorteile erforderlich ist. Wenn Sie also beispielsweise für Ihr Programm eine Datenstruktur benötigen, die eine Ansammlung von Daten speichern soll, Sie deren Menge aber vor Ablauf des Programms nicht absehen können. Oder wenn Sie eine Datenstruktur benötigen, die zur Laufzeit nach Bedarf wachsen oder schrumpfen kann. Wenn Sie in Ihrem Programm große Objekte oder andere Speicherblöcke übergeben müssen, ist die Verwendung von Zeigern möglicherweise ebenfalls die richtige Wahl. Liegt allerdings keine der hier beschriebenen Situationen vor, sollten Sie Zeigern und dynamischer Speicherverwaltung gegenüber skeptisch sein.

Angesichts des schlechten Rufs, den Zeiger als eines der schwierigsten Merkmale von C++ genießen, könnte man auf den Gedanken kommen, dass kein Programmierer versuchen würde, Zeiger zu verwenden, wenn es nicht unbedingt notwendig ist. Ich wurde allerdings schon viele Male eines Besseren belehrt. Manchmal reden Programmierer sich selbst ein, dass ein Zeiger benötigt wird. Stellen Sie sich vor, dass Sie eine Funktion aufrufen, die Sie nicht geschrieben haben, etwa aus einer Bibliothek oder aus der Programmierschnittstelle einer Anwendung, die folgenden Prototyp besitzt:

void compute(int input, int* output);

Man könnte glauben, dass diese Funktion nicht in C++, sondern in C programmiert wurde und daher einen Zeiger statt einer Referenz (&) verwendet, um auf externe Parameter zugreifen zu können. Beim Aufruf dieser Funktion könnte ein unachtsamer Programmierer nun Folgendes tun:

int num1 = 10;
int* num2 = new int;
compute(num1, num2);

Dieser Code ist ineffizient bezüglich des Speicherplatzes, weil er einen Zeiger verwendet, obwohl dies unnötig ist. Statt des Speicherplatzes für zwei Ganzzahlen belegt der Code den Speicherplatz für zwei Ganzzahlen und einen Zeiger. Der Code ist auch ineffizient bezüglich der Zeit, denn die überflüssige Reservierung des Speicherplatzes kostet Zeit (wie im nächsten Abschnitt erläutert wird). Und zu guter Letzt muss der Programmierer nun auch daran denken, den reservierten Speicher mit delete wieder freizugeben. All dies lässt sich vermeiden, wenn man sich die andere Eigenschaft des &-Operators zunutze macht, die es Ihnen erlaubt, die Speicheradresse einer statisch allozierten Variablen abzufragen:

int num1 = 10;
int num2;
compute(num1, &num2);

Streng genommen verwenden wir in der zweiten Version noch immer einen Zeiger, aber wir tun dies implizit, ohne eine Zeigervariable oder dynamische Speicherreservierung.

4.4 Speicherverwaltung

Um zu verstehen, wie die dynamische Speicherverwaltung es uns erlaubt, die Größe einer Datenstruktur zur Laufzeit festzulegen und Speicher gemeinsam zu nutzen, müssen wir uns etwas näher ansehen, wie die Speicherreservierung überhaupt funktioniert. Hier handelt es sich um eines der Themenfelder, bei denen angehende Programmierer wirklich davon profitieren, C++ zu lernen. Früher oder später muss jeder Programmierer die Arbeitsweise der Speicherverwaltung moderner Computer verstanden haben und C++ zwingt Sie dazu, sich dieser Aufgabe zu stellen. Andere Programmiersprachen verbergen oft viele der schmutzigen kleinen Einzelheiten des Speichersystems, sodass angehende Programmierer zu dem Schluss gelangen, dass diese Details bedeutungslos sind, was aber schlicht und einfach nicht stimmt. Bedeutungslos sind diese Einzelheiten nur, solange alles problemlos funktioniert. Sobald es jedoch zu Schwierigkeiten kommt, stellt die Unkenntnis des zugrunde liegenden Speichermodells ein unüberwindbares Hindernis zwischen Programmierer und Problemlösung dar.

4.4.1 Stack und Heap

C++ reserviert an zwei Stellen Speicherplatz: auf dem Stack („Stapel”) und auf dem Heap („Haufen”). Wie die Bezeichnungen schon vermuten lassen, ist der Stack gut organisiert und ordentlich, während es auf dem Heap zusammenhangslos und chaotisch zugeht. Die Bezeichnung Stack oder Stapelspeicher ist besonders aussagekräftig, weil sie dabei hilft, sich die zusammenhängende Struktur der Speicherreservierung vorzustellen. Stellen Sie sich einen Stapel Kisten wie in Abbildung 4.1 (a) vor. Wenn Sie eine Kiste einlagern möchten, stellen Sie diese oben auf den Stapel. Um eine bestimmte Kiste aus dem Stapel zu entfernen, müssen Sie zunächst die darüber befindlichen Kisten beiseiteräumen. Für die praktische Programmierung bedeutet dies, dass es nach der Reservierung eines Speicherblocks (einer Kiste) auf dem Stack keine Möglichkeit gibt, dessen Größe zu ändern, weil dem Block jederzeit unmittelbar andere Speicherblöcke (weitere Kisten oberhalb Ihrer Kiste) folgen können.

Abb. 4.1: Ein Stapel Kisten und ein Stack mit Funktionsaufrufen

Sie können in C++ einen eigenen Stack anlegen und diesen in einem bestimmten Algorithmus benutzen, aber dessen ungeachtet gibt es einen Stack, den Ihr Programm immer verwendet, nämlich den Laufzeit-Stack des Programms. Immer wenn eine Funktion aufgerufen wird (das schließt die main-Funktion ein), wird auf diesem Stack ein Speicherblock reserviert. Dieser Speicherblock wird als Aktivierungseintrag (englisch activation record) bezeichnet. Eine vollständige Erläuterung des Inhalts des Aktivierungseintrags würde den Rahmen dieses Buches sprengen, aber für Sie als Problemlöser genügt es zu wissen, dass der Aktivierungseintrag im Wesentlichen als Speicherplatz für Variablen dient. Der Speicherplatz aller lokalen Variablen, inklusive der Parameter von Funktionen, wird innerhalb des Aktivierungseintrags reserviert. Betrachten wir ein Beispiel:

int functionB(int inputValue) {
    return inputValue - 10;
}
int functionA(int num) {
    int localVariable = functionB(num * 10);
    return localVariable;
}
int main() {
    int x = 12;
    int y = functionA(x);
    return 0;
}

Die main-Funktion ruft functionA auf, die ihrerseits functionB aufruft. Abbildung 4.1 (b) zeigt eine vereinfachte Ansicht des Laufzeit-Stacks, unmittelbar bevor die return-Anweisung in functionB ausgeführt wird. Auf dem Stack befinden sich die Aktivierungseinträge aller drei Funktionen in einem zusammenhängenden Speicherbereich, wobei die main-Funktion am unteren Ende des Stacks liegt. (Um die Sache noch verwirrender zu machen: Es ist möglich, dass der Stack an der höchstmöglichen Speicheradresse beginnt und in Richtung niedrigerer Speicheradresse nach unten wächst, statt von unten nach oben in Richtung höherer Speicheradressen. Sie können dies hier allerdings getrost ignorieren.) Von der Logik her befindet sich der Aktivierungseintrag der main-Funktion am unteren Ende des Stacks, darüber folgt der Aktivierungseintrag von functionA und ganz oben befindet sich derjenige von functionB. Keiner der beiden unteren Aktivierungseinträge kann entfernt werden, solange der Aktivierungseintrag von functionB nicht entfernt wird.

Der Stack ist in hohem Maße organisiert, der Heap hingegen kaum. Stellen Sie sich wieder vor, dass Sie Objekte in Kisten verstauen, diese Kisten jedoch so instabil sind, dass sie sich nicht übereinanderstapeln lassen. Ihnen steht ein großer, anfangs leerer Raum zum Lagern der Kisten zur Verfügung und es steht Ihnen frei, an welchen Positionen im Raum Sie die Kisten deponieren. Die Kisten sind jedoch ziemlich schwer und daher möchten Sie, sobald Sie eine der Kisten platziert haben, diese an Ort und Stelle belassen, bis Sie die Kiste irgendwann wieder aus dem Raum entfernen. Dieses System besitzt im Vergleich zum Stack sowohl Vor- als auch Nachteile. Einerseits ist dieses Speichersystem sehr flexibel und ermöglicht jederzeit den Zugang zu den Inhalten der Kisten. Andererseits wird der Raum schon bald im Chaos versinken. Wenn dann alle Kisten auch noch von unterschiedlicher Größe sind, wird es besonders schwierig, die verfügbare Bodenfläche auszunutzen. So entstehen viele Lücken zwischen den Kisten, die zu klein sind, um dort weitere Kisten abzustellen. Da es nicht ohne Weiteres möglich ist, die Kisten zu verschieben, führt das Entfernen einzelner Kisten nur zu weiteren, schwer wieder zu belegenden Lücken, statt die ursprüngliche Weite des einst leeren Raums wiederherzustellen. Für die praktische Programmierung bedeutet dies, dass sich der Heap wie die Bodenfläche dieses Raumes verhält. Jeder Speicherblock besteht aus einer Reihe zusammenhängender Speicheradressen. Wenn während des Programmablaufs ständig Speicher reserviert und wieder freigegeben wird, führt das schließlich zu einer Menge Lücken zwischen den verbliebenen reservierten Speicherblöcken. Dieses Phänomen ist unter dem Begriff Speicherfragmentierung bekannt.

Jedes Programm besitzt einen eigenen Heap, auf dem Speicherplatz dynamisch reserviert wird. In C++ wird dazu üblicherweise das Schlüsselwort new verwendet, Ihnen werden aber auch Aufrufe der alten C-Funktionen zur Speicherreservierung begegnen, wie etwa malloc. Jeder Aufruf von new (oder malloc) stellt einen Speicherbereich zur Seite und liefert einen Zeiger darauf zurück, während jeder Aufruf von delete (bzw. free, wenn der Speicher mittels malloc reserviert wurde) den Speicher wieder dem verfügbaren Heapspeicher hinzufügt. Aufgrund der Fragmentierung sind jedoch nicht alle Speicherbereiche gleich nützlich. Wenn unser Programm zunächst für die Variablen A, B und C Heapspeicher alloziert, erwarten wir, dass die entsprechenden Speicherblöcke zusammenhängen. Wenn wir nun B wieder freigeben, entsteht eine Lücke, die nur durch eine Speicherreservierung für ein Objekt von der Größe Bs (oder kleiner) wieder (teilweise) geschlossen werden kann, solange nicht auch A oder C wieder freigegeben werden.

Abbildung 4.2 verdeutlicht die Situation. In Teil (a) sehen Sie die mit Kisten zugestellte Bodenfläche des Raums. Der Raum war früher vermutlich aufgeräumt, aber im Laufe der Zeit wurde die Raumaufteilung immer planloser. Die kleine Kiste (b) kann nicht mehr abgestellt werden, obwohl die unbelegte Bodenfläche des Raums viel größer als die Grundfläche der Kiste ist. In Teil (c) ist ein kleiner Heap dargestellt. Die Quadrate aus gestrichelten Linien stellen die kleinsten (unteilbaren) Speichereinheiten dar, wobei es sich um Bytes, Datenwörter oder größere Speicherbereiche handeln kann, je nachdem, wie der Heap verwaltet wird. Die getönten Flächen repräsentieren zusammenhängende reservierte Speicherbereiche. Zur Verdeutlichung sind einige der Speichereinheiten eines solchen Speicherbereichs nummeriert. Ähnlich wie bei der fragmentierten Bodenfläche besitzt der fragmentierte Heap mehrere unzusammenhängende Speicherbereiche, wodurch deren Verwendbarkeit eingeschränkt ist. Es gibt insgesamt 85 ungenutzte Speichereinheiten, jedoch ist der größte zusammenhängende Speicherbereich (markiert durch den Pfeil) nur 17 Einheiten lang. Mit anderen Worten: Entspräche jede der Einheiten einem Byte, könnte dieser Heap eine Speicheranforderung durch new von mehr als 17 Bytes nicht erfüllen, obwohl 85 Bytes frei sind.

Abb. 4.2: Eine fragmentierte Bodenfläche, eine Kiste, die nicht abgestellt werden kann und fragmentierter Arbeitsspeicher

4.4.2 Arbeitsspeicher

Das erste Thema, das in der Praxis auftritt, ist die Beschränkung des Arbeitsspeichers auf die erforderliche Größe. Moderne Computersysteme besitzen eine derartige Menge an Arbeitsspeicher, dass man geneigt ist, ihn für eine unerschöpfliche Ressource zu halten, jedoch steht jedem Programm tatsächlich nur ein begrenzter Betrag an Arbeitsspeicher zur Verfügung. Programme müssen außerdem effizienten Gebrauch vom Arbeitsspeicher machen, um das System insgesamt nicht zu verlangsamen. Bei einem Multitasking-Betriebssystem (also bei nahezu allen modernen Betriebssystemen) bringt jedes einzelne Byte Arbeitsspeicher, das ein Programm verschwendet, das System als Ganzes näher an den Punkt heran, an dem für die aktiven Programme nicht mehr ausreichend Arbeitsspeicher bereitsteht. Wenn es so weit ist, beginnt das System, Teile der verschiedenen Programme in schnellem Wechsel auf die Festplatte auszulagern und andere Programmteile wieder einzulesen, sodass es nur noch im Schneckentempo arbeitet. Dieser Zustand ist unter der Bezeichnung Thrashing bekannt.

Neben dem Streben nach möglichst geringem Arbeitsspeicherbedarf eines Programms ist außerdem zu beachten, dass Stack und Heap eine maximale Größe besitzen. Um dies zu belegen, müssen wir nur Kilobyte für Kilobyte Speicher auf dem Heap reservieren, bis es irgendwann „knallt”:

const int intsPerKilobyte = 1024 / sizeof(int);
while (true) {
    int *oneKilobyteArray = new int[intsPerKilobyte];
}

Ich muss betonen, dass es sich hier natürlich um grauenhaften Code handelt, der ausschließlich Demonstrationszwecken dient. Wenn Sie diesen Code auf Ihrem System ausführen möchten, sollten Sie sicherheitshalber vorher Ihre Arbeit sichern. Es sollte Folgendes passieren: Das Programm wird angehalten und das Betriebssystem beschwert sich, dass der erzeugte Code einen Ausnahmefehler des Typs bad_alloc nicht abgefangen hat. Dieser Ausnahmefehler wird von new ausgelöst, wenn keiner der freien Speicherbereiche im Heap hinreichend groß ist, um die Speicheranforderung zu erfüllen. Wenn der Heapspeicher ausgeht, wird dies als Heap-Überlauf (englisch heap overflow) bezeichnet. Auf manchen Systemen sind Heap-Überläufe fast alltäglich, während das betroffene Programm auf anderen Systemen – lange bevor der bad_alloc-Ausnahmefehler ausgelöst wird – ein Thrashing verursacht. (Auf meinem System scheiterte der Aufruf von new erst, nachdem ich mehr als 2 Gigabyte Heapspeicher angefordert hatte.)

Beim Laufzeit-Stack kann es zu einer vergleichbaren Situation kommen. Jeder Funktionsaufruf reserviert Speicher auf dem Stack, und jeder Aktivierungseintrag belegt eine gewisse Menge Speicher, selbst wenn eine Funktion keine Parameter besitzt und keine lokalen Variablen verwendet. Die einfachste Methode, dies zu demonstrieren, ist die Programmierung einer außer Kontrolle geratenden rekursiven Funktion:

int count = 0;
void stackOverflow() {
     count++;
     stackOverflow();
}
int main() {
    stackOverflow();
    return 0;
}

In diesem Code gibt es eine globale Variable , was für gewöhnlich schlechter Stil ist, jedoch benötige ich hier eine Variable, die während der rekursiven Aufrufe ihren Wert beibehält. Da die Deklaration dieser Variablen außerhalb der Funktion stattfindet, wird im Aktivierungseintrag der Funktion auch kein Speicher reserviert, und es gibt auch keine Parameter oder lokale Variablen. Die Funktion erhöht nur den Zähler count und ruft sich selbst rekursiv auf . Rekursion kommt in Kapitel 6 noch ausführlich zur Sprache. Sie dient hier nur dazu, die Kette der Funktionsaufrufe so lang wie möglich zu machen. Der Aktivierungseintrag einer Funktion verbleibt auf dem Stack, bis die Funktion beendet wird. Wenn also stackOverflow erstmals von der main-Funktion aus aufgerufen wird, wird ein Aktivierungseintrag auf dem Laufzeit-Stack abgelegt, der nicht entfernt werden kann, bevor auch dieser erste Aufruf der Funktion beendet ist. Das aber wird niemals passieren, denn die Funktion ruft stackOverflow ein zweites Mal auf, wodurch ein weiterer Aktivierungseintrag auf dem Stack abgelegt wird, der stackOverflow ein drittes Mal aufruft, usw. All diese Aktivierungseinträge türmen sich auf dem Stack, bis es irgendwann keinen Platz mehr gibt. Auf meinem System besitzt count beim Absturz des Programms einen Wert von ungefähr 4.900. Meine Entwicklungsumgebung, Visual Studio, reserviert standardmäßig 1 Megabyte Speicherplatz für den Stack, was bedeutet, dass jeder dieser Funktionsaufrufe, selbst ohne lokale Variablen oder Parameter, einen Aktivierungseintrag von mehr als 200 Bytes Länge erzeugt.

4.4.3 Lebensdauer

Die Lebensdauer einer Variablen gibt die Zeitspanne zwischen der Reservierung des Speicherplatzes und dessen Freigabe an. Bei Variablen, die auf dem Stack abgelegt werden, also lokalen Variablen oder Parametern, wird die Lebensdauer implizit gehandhabt. Beim Aufruf der Funktion wird Speicherplatz für die Variable reserviert, der wieder freigegeben wird, sobald die Funktion beendet ist. Bei Variablen, die auf dem Heap abgelegt werden, also solchen, die durch den Aufruf von new dynamisch erzeugt werden, sind wir selbst für die Lebensdauer zuständig. Die Verwaltung der Lebensdauer dynamisch erzeugter Variablen ist für jeden C++-Programmierer ein Fluch. Das offensichtlichste Problem sind die gefürchteten Speicherlecks, bei denen Heapspeicher reserviert wird, der nie wieder freigegeben wird, obwohl keine Zeiger mehr darauf verweisen. Ein einfaches Beispiel:

int *intPointer = new int;
intPtr = NULL;

Hier wird zunächst ein Zeiger auf eine Ganzzahl deklariert und initialisiert, indem mittels new Speicher auf dem Heap reserviert wird. In der zweiten Zeile weisen wir dem Zeiger NULL zu (NULL ist nichts weiter als eine andere Bezeichnung für die Zahl 0). Die mittels new allozierte Ganzzahl existiert jedoch nach wie vor. Sie sitzt, einsam und verloren, im Heap und wartet darauf, wieder freigegeben zu werden, was jedoch niemals geschehen wird. Zur Freigabe eines Speicherblocks dient der Befehl delete, gefolgt von einem Zeiger auf den fraglichen Block. Die Freigabe der Ganzzahl ist uns aber nicht möglich, da der Zeiger auf diesen Block nicht mehr vorhanden ist. Wenn wir dem obigen Code die Anweisung delete intPointer folgen lassen, erhalten wir eine Fehlermeldung, weil intPointer nun 0 ist.

Es kommt gelegentlich vor, dass statt des nicht wieder freigegebenen Arbeitsspeichers das gegenteilige Problem auftritt, nämlich dass derselbe Speicherbereich mehrmals freigegeben wird, wodurch ein Laufzeitfehler ausgelöst wird. Auf den ersten Blick lässt sich das offenbar leicht vermeiden: Rufen Sie gefälligst delete nicht mehrmals mit derselben Variablen auf! Doch so einfach ist es nicht: Möglicherweise gibt es mehrere Zeiger, die auf denselben Speicherbereich verweisen. Ist dies der Fall und wir rufen delete mit einer dieser Variablen auf, haben wir den Speicherbereich für alle diese Variablen wieder freigegeben. Weisen wir den anderen Variablen nicht ausdrücklich NULL zu, werden diese zu verwaisten Referenzen (englisch dangling references) und der Aufruf von delete mit einer dieser Variablen als Parameter führt zu einem Laufzeitfehler.

4.5 Aufgabenstellungen mit Zeigern

Es ist an der Zeit, einige Aufgabenstellungen zu betrachten. Sehen wir uns an, wie Zeiger und dynamische Speicherverwaltung dazu verwendet werden können, diese zu lösen. Wir werden zunächst einige dynamisch allozierte Arrays einsetzen und zeigen, wie man bei all diesen Verarbeitungsschritten den Heapspeicher im Auge behält. Danach gehen wir ans Eingemachte und beschäftigen uns mit einer wahrlich dynamischen Datenstruktur.

4.5.1 Zeichenketten variabler Länge

Bei der ersten Aufgabe werden wir Funktionen zur Bearbeitung von Zeichenketten erstellen. Wir verwenden den Begriff Zeichenkette (oder String) hier im ganz allgemeinen Sinn: eine Reihe von Zeichen, unabhängig davon, wie diese gespeichert werden. Wir benötigen drei Funktionen zur Bearbeitung der Strings.

Aufgabe: Bearbeitung von Zeichenketten variabler Länge

Schreiben Sie unter Verwendung des Heapspeichers die folgenden drei erforderlichen Funktionen:

append Diese Funktionen nimmt eine Zeichenkette und ein Zeichen entgegen und hängt das Zeichen am Ende der Zeichenkette an.

concatenate Diese Funktion nimmt zwei Zeichenketten entgegen und hängt die zweite Zeichenkette am Ende der ersten Zeichenkette an.

characterAt Diese Funktionen nimmt eine Zeichenkette und eine Zahl entgegen und gibt das Zeichen an der bezeichneten Position zurück, wobei sich das erste Zeichen der Zeichenkette an der Position 0 befindet.

Schreiben Sie den Code unter der Annahme, dass characterAt sehr häufig aufgerufen wird, während die anderen beiden Funktionen nur relativ selten verwendet werden. Die Effizienz der Operationen sollte dabei der Häufigkeit der Aufrufe Rechnung tragen.

Wir benötigen hier eine Methode zum Speichern der Zeichenkette, die eine schnelle Implementierung der Funktion characterAt gestattet, was bedeutet, dass wir einen Weg finden müssen, ein bestimmtes Zeichen so schnell wie möglich innerhalb der Zeichenkette zu lokalisieren. Wie Sie aus dem hervorgehenden Kapitel bereits wissen, sind Arrays insbesondere für den wahlfreien Zugriff bestens geeignet. Lösen wir die Aufgabe also mit einem char-Array. Die Funktionen append und concatenate ändern die Größe des Arrays so, dass wir auf all die Problemstellungen treffen, die wir bereits untersucht haben. Da in der Aufgabenstellung nicht ausdrücklich von einer begrenzten Länge der Zeichenkette die Rede ist, können wir nicht einfach einen hohen Wert für die Größe unserer Arrays verwenden und hoffen, dass dies schon ausreichen wird. Stattdessen müssen wir die Größe unserer Arrays zur Laufzeit festlegen.

Lassen Sie uns zunächst eine typedef für unsere Zeichenkette erstellen. Wir wissen, dass die Arrays dynamisch erstellt werden, daher muss unsere Zeichenkette ein char-Zeiger sein:

typedef char * arrayString

Nachdem das erledigt ist, können wir uns den Funktionen zuwenden. Wir bleiben unserem Prinzip treu, mit Bekanntem anzufangen, und erstellen die characterAt-Funktion:

char characterAt(arrayString s, int position) {
     return s[position];
}

Aus Kapitel 3 wissen Sie, dass wir auf die Elemente eines Arrays mittels der üblichen Array-Notation zugreifen können, wenn einem Zeiger die Speicheradresse eines Arrays zugewiesen ist. Beachten Sie jedoch, dass unschöne Dinge passieren können, wenn position nicht auch tatsächlich ein gültiger Index des Arrays s ist, und der vorliegende Code überlässt die Verantwortung für die Überprüfung des zweiten Parameters der aufrufenden Funktion. In den Übungen kommen Alternativen für solch eine Situation zur Sprache. Kümmern wir uns um die append-Funktion. Wir können uns die allgemeine Arbeitsweise dieser Funktion schon vorstellen, aber bevor wir die Einzelheiten in die Tat umsetzen, sollten wir ein Beispiel betrachten. Ich bezeichne dieses Vorgehen als Lösung per Fallbeispiel.

Beginnen wir mit nicht-trivialen Eingabewerten für das Programm. Notieren Sie sich die Details dieser Eingabe zusammen mit den Einzelheiten der Ausgabe. Wenn Sie nun den Code schreiben, müssen Sie dafür sorgen, dass Sie den allgemeinen Fall behandeln, und stets zwei Mal hinsehen, wie die einzelnen Schritte die eingegebenen Beispielwerte verändern, um nach und nach die erwünschte Ausgabe zu erhalten. Diese Vorgehensweise ist besonders im Umgang mit Zeigern und dynamischer Speicherverwaltung hilfreich, weil hierbei so viele der Vorgänge im Programm außerhalb des normalen Blickfelds liegen. Ein Fallbeispiel mit Papier und Bleistift durchzuarbeiten, zwingt Sie dazu, sämtliche im Arbeitsspeicher geänderten Werte nachzuverfolgen, nicht nur die Variablen, sondern auch die Werte auf dem Heap.

Nehmen wir an, dass wir zunächst die Zeichenkette test eingeben. Das bedeutet, dass sich auf dem Heap ein Array von Zeichen befindet, und zwar in der Reihenfolge t, e, s und t. Wir möchten nun mit unserer Funktion ein Ausrufezeichen daran anhängen. Abbildung 4.3 zeigt den Zustand des Arbeitsspeichers vor (a) und nach (b) diesem Vorgang. In den folgenden Diagrammen gehört alles auf der linken Seite der gestrichelten Linie zum Arbeitsspeicher des Stacks (lokale Variablen oder Parameter) und alles rechts davon zum Heapspeicher, der mittels new reserviert wurde.

Beim Betrachten dieser Abbildung fällt mir sogleich ein potenzielles Problem ins Auge. So wie wir die Zeichenketten implementiert haben, wird die Funktion ein neues Array erstellen, das um ein Element größer als das ursprüngliche Array ist, und dann alle Zeichen des ersten Arrays ins zweite kopieren. Aber woher sollen wir wissen, wie groß das erste Array ist? Aus dem vorhergehenden Kapitel wissen wir, dass wir die Größe unserer Arrays selbst nachverfolgen müssen. Es fehlt also noch etwas.

Abb. 4.3: Die Zustände vor (a) und nach (b) dem Anhängen eines Zeichens

Hätten wir Erfahrung mit den Stringfunktionen der C/C++-Standardbibliothek, wäre uns der noch fehlende Bestandteil schon bekannt. Fehlt diese Erfahrung, lässt sich diese Frage aber auch schnell klären. Eine der Vorgehensweisen beim Problemlösen ist es, nach Analogien zu suchen. Vielleicht sollten wir andere Aufgabenstellungen, in denen irgendeine Länge unbekannt ist, in unsere Überlegungen einbeziehen. In Kapitel 2 haben wir bei der Aufgabe „Test einer Luhn-Prüfsumme” ID-Nummern beliebiger Länge verarbeitet. Bei dieser Aufgabenstellung war unbekannt, wie viele Ziffern der Benutzer eingibt. Schließlich haben wir eine while-Schleife programmiert, die abbricht, sobald ein Zeilenende eingegeben wird.

Leider gibt es am Ende eines Arrays kein Zeilenendezeichen. Und wenn wir im letzten Element unserer Arrays selbst ein Zeilenendezeichen ablegen? Wir könnten dann die Länge unserer Arrays auf dieselbe Weise ermitteln wie die Anzahl der Ziffern der ID-Nummer. Der einzige Nachteil dieses Ansatzes ist es, dass die Verwendung des Zeilenendezeichens in den Zeichenketten – außer als letztes Zeichen – nicht mehr möglich ist. Das ist, je nachdem, wofür die Zeichenketten verwendet werden, nicht unbedingt eine große Einschränkung, aber um maximale Flexibilität zu erzielen, wäre es am besten, ein Zeichen auszuwählen, das keiner Verwechslungsgefahr mit Zeichen unterliegt, die man vielleicht verwenden möchte. Wir werden daher eine Null verwenden, um unsere Arrays zu begrenzen, denn die Null kennzeichnet im ASCII und anderen Zeichencodesystemen ein Nullzeichen. Genau diese Methode verwendet auch die C/C++-Standardbibliothek.

Nachdem auch das erledigt ist, wollen wir nun genauer klären, was die append-Funktion mit der Beispieleingabe anstellen soll. Wir wissen, dass die Funktion zwei Parameter besitzt, wobei der erste vom Typ arrayString ist, also ein Zeiger auf ein char-Array im Heap, und der zweite das Zeichen (vom Typ char) enthält, das der Zeichenkette angehängt werden soll. Fahren wir der Einfachheit halber damit fort, ein Skelett der append-Funktion und den Code zum Testen zu schreiben:

void append(arrayString& s , char c) {
 }
void appendTester() {
arrayString a = new char[5];
     a[0] = 't'; a[1] = 'e'; a[2] = 's'; a[3] = 't'; a[4] = 0;
     append(a, '!');
     cout << a << "\n";
}

Die appendTester-Funktion reserviert auf dem Heap Speicher für unseren String . Beachten Sie, dass die Größe des Arrays fünf betragen muss, damit wir neben den vier Zeichen des Worts test auch das abschließende Nullzeichen zuweisen können . Nun wird die append-Funktion aufgerufen , die gegenwärtig nur eine leere Hülle der Funktion darstellt. Beim Schreiben dieses Funktionsskeletts bemerkte ich, dass für den Parameter arrayString eine Referenz (&) verwendet werden muss , weil die Funktion ein neues Array im Heap erstellt. Das ist ja der eigentliche Sinn, warum hier dynamische Speicherverwaltung zum Einsatz kommt: Bei jeder Änderung der Stringlänge soll ein neues Array erzeugt werden. Der Wert der Variablen a bei der Übergabe an append sollte daher ein anderer sein als nach Abarbeitung der Funktion, weil a ja dann auf ein neues Array verweisen muss. Beachten Sie auch, dass wir zur Überprüfung des Werts das durch den Zeiger a referenzierte Array direkt dem Ausgabedatenstrom übergeben können , weil wir als abschließendes Zeichen das von der Standardbibliothek erwartete Nullzeichen verwenden.

Abbildung 4.4 zeigt, wie die Funktion in unserem Fallbeispiel vorgeht. Die abschließenden Nullzeichen sind zur Verdeutlichung als NULL gekennzeichnet. Im Zustand nach der Abarbeitung (b) verweist s auf einen neuen reservierten Speicherbereich.

Abb. 4.4: Der Zustand des Arbeitsspeichers vor (a) und nach (b) Abarbeitung der append-Funktion

Das ursprüngliche Array ist in einem getönten Kasten dargestellt. In derartigen Diagrammen verwende ich getönte Kästen, um wieder freigegebenen Arbeitsspeicher zu kennzeichnen. Die Darstellung des wieder freigegebenen Speichers in den Diagrammen soll uns daran erinnern, diese Freigabe auch tatsächlich vorzunehmen.

Nachdem durch die Visualisierung die Vorgehensweise klar geworden ist, können wir die folgende Funktion schreiben:

void append(arrayString& s, char c) {
    int oldLength = 0;
    while (s[oldLength] != 0) { oldLength++;
    }
    arrayString newS = new char[oldLength + 2];
    for (int i = 0; i < oldLength; i++) {
        newS[i] = s[i];
    }
    newS[oldLength] = c;
    newS[oldLength + 1] = 0;
    delete[] s;
    s = newS;
}

Hier passiert eine ganze Menge, gehen wir den Code also schrittweise durch. Zu Beginn der Funktion steht eine Schleife, die prüft, ob das Nullzeichen erreicht ist, die das Ende des Arrays kennzeichnet . Wenn diese Schleife abgearbeitet ist, enthält oldLength die Anzahl der gültigen Zeichen im Array (ohne das abschließende Nullzeichen). Dann reservieren wir auf dem Heap Speicherplatz für ein neues Array der Länge oldLength+2 . Hierbei handelt es sich um eines dieser Details, die so schwierig sind, wenn man alles im Kopf durchdenkt, jedoch keine Probleme bereiten, wenn man über ein Diagramm verfügt. Wenn Sie dem Code weiter folgen und Abbildung 4.5 betrachten, wird deutlich, dass oldLength in unserem Beispiel vier ist, denn das Wort test ist vier Zeichen lang. Das neue Array in Teil (b) der Abbildung ist sechs Zeichen lang, weil wir zusätzlichen Platz für das Ausrufezeichen und das abschließende Nullzeichen benötigen.

Wenn der Speicherplatz für das neue Array reserviert ist, kopieren wir alle gültigen Zeichen vom alten Array in das neue . Wir weisen dann den jeweiligen Positionen im Array das anzuhängende Zeichen und das abschließende Nullzeichen zu . Hier ist das Diagramm ebenfalls hilfreich. Aus Abbildung 4.5 ist ersichtlich, wie der Wert von oldLength zustande kommt und auf welche Position dieser Wert im neuen Array verweist. Dank dieser visuellen Gedächtnisstütze ist es einfach, die korrekten Indizes für die beiden Zuweisungen herauszufinden.

Abb. 4.5: Lokale Variable, Parameter und reservierter Speicher vor und nach Abarbeitung der append-Funktion

Bei den letzten beiden Zeilen der append-Funktion dreht sich alles um den getönten Kasten in Teil (b) der Abbildung 4.5. Um ein Speicherleck zu verhindern, müssen wir den Heapspeicher für das Array, auf das der Parameter s ursprünglich verwiesen hat, wieder freigeben . Schließlich sorgen wir vor dem Verlassen der Funktion dafür, dass s auf das neue, längere Array verweist . Einer der Gründe dafür, dass Speicherlecks bei der Programmierung in C++ so häufig sind, ist die Tatsache, dass es zu keinerlei Beeinträchtigungen des Programms und des Systems insgesamt kommt, solange die Gesamtsumme der Speicherlecks nicht ein erhebliches Ausmaß erreicht. Daher werden die Speicherlecks von den Programmierern beim Testen oft überhaupt nicht bemerkt. Wir als Programmierer müssen daher äußerst gewissenhaft sein und stets die Lebensdauer der Speicherreservierungen auf dem Heap berücksichtigen. Überlegen Sie sich also jedes Mal, wenn Sie das Schlüsselwort new verwenden, wann und wo das korrespondierende delete auftauchen sollte.

Beachten Sie hier ebenfalls, wie alle Teile dieser Funktion sich unmittelbar aus dem Diagramm ergeben. Knifflige Programmierung wird durch gute Diagramme sehr viel weniger kompliziert und ich würde mir wünschen, dass viel mehr angehende Programmierer sich die Zeit nähmen, ein wenig zu zeichnen, bevor sie anfangen, Code zu schreiben. Das führt uns wieder zu dem grundlegenden Prinzip beim Lösen von Problemen: niemals ohne Plan. Wenn Sie über ein gut gemachtes Diagramm für ein Fallbeispiel verfügen, besitzen Sie sozusagen eine Landkarte mit eingezeichnetem Weg, der Sie auf einer langen Reise zum Ziel führt. Das Erstellen eines Diagramms mag anfangs etwas mehr Aufwand bedeuten, der sich am Ende aber oft auszahlt und potenziell einen viel größeren Aufwand und Frustrationen vermeiden kann.

Tipp

Erstellen von Diagrammen

Zum Erstellen eines Diagramms benötigen Sie nur Papier und Bleistift. Wenn die Zeit es erlaubt, rate ich allerdings dazu, ein Zeichenprogramm zu verwenden. Es gibt Zeichenprogramme, die eigens über Vorlagen für Programmieraufgaben verfügen, aber für den Anfang tut es jedes beliebige Vektorgrafikprogramm (der Begriff Vektor bedeutet hier, dass das Programm Objekte wie Linien und Kurven verwendet, keine Pixelgrafik, wie z. B. Photoshop). Ich habe die Abbildungen in diesem Buch mit dem Programm Inkscape (http://inkscape.org) erstellt, das kostenlos verfügbar ist. Das Erstellen der Diagramme am Computer ermöglicht es Ihnen, diese am selben Speicherort wie den dazugehörigen Code abzulegen. Wahrscheinlich werden die Diagramme auch etwas übersichtlicher oder eleganter und somit leichter verständlich sein, wenn Sie sie nach längerer Zeit wieder hervorholen. Zu guter Letzt ist es ein Kinderspiel, ein am Computer erstelltes Diagramm zu kopieren und abzuändern, so wie ich aus Abbildung 4.4 die Abbildung 4.5 gemacht habe. Und wenn Sie nur schnell ein paar Notizen machen möchten, können Sie jederzeit einen Ausdruck verwenden, um darauf herumzukritzeln.

Zurück zur append-Funktion! Der Code sieht zwar robust aus, aber bedenken Sie, dass wir ihn anhand eines Fallbeispiels entwickelt haben. Wir sollten daher nicht übermütig werden und annehmen, dass der Code in allen denkbaren Fällen korrekt funktioniert. Wir müssen insbesondere ein paar Spezialfälle testen. Im Zusammenhang mit der Programmierung ist mit einem Spezialfall eine Situation gemeint, in der gültige Eingabedaten beim normalen Durchlaufen des Codes zu fehlerhaften Resultaten führen.

Beachten Sie, dass hiermit eben nicht ungültige Daten gemeint sind, wie etwa Daten außerhalb des gültigen Eingabebereichs. Der Code in diesem Buch setzt bei den verschiedenen Programmen und Funktionen vernünftige Eingabedaten voraus. Wenn ein Programm beispielsweise eine Reihe durch Kommas getrennter Zahlen als Eingabewert erwartet, gehen wir davon aus, dass es auch solch eine Eingabe erhält, also keine zusätzlichen Zeichen oder Werte, die keine Zahlen sind, usw. Diese Annahme ist erforderlich, um den Code kurz zu halten und um es zu vermeiden, immer und immer wieder denselben Code zum Überprüfen der Werte zu wiederholen. Im wahren Leben müssen allerdings sinnvolle Vorsichtsmaßnahmen gegen ungültige Eingaben getroffen werden. Man spricht hier von der Robustheit eines Programms. Ein robustes Programm kommt auch mit ungültigen Eingabewerten zurecht. Statt abzustürzen, könnte das Programm dem Benutzer beispielsweise eine Fehlermeldung anzeigen.

Spezialfälle überprüfen

Sehen wir uns die append-Funktionen erneut an und halten nach Spezialfällen Ausschau. Oder anders ausgedrückt: Stellen wir sicher, dass es unter den denkbaren möglichen Eingaben keine exzentrischen Werte gibt. Bei den meisten Spezialfällen sind Extremwerte, wie der kleinste oder der größte mögliche Wert, die Übeltäter. Bei der append-Funktion gibt es keine maximale Größe des String-Arrays, wohl aber eine minimale. Wenn die Zeichenkette keine gültigen Zeichen besitzt, entspricht dies einem Array mit einem Zeichen (bei dem es sich um das abschließende Nullzeichen handelt). Lassen Sie uns wie zuvor zur Verdeutlichung ein Diagramm betrachten. Nehmen wir also an, dass wir das Ausrufezeichen einem Nullzeichen anhängen (Abbildung 4.6).

Abb. 4.6: Test der append-Funktion mit der kleinstmöglichen Zeichenkette

Wenn man das Diagramm betrachtet, sieht es nicht so aus, als ob es sich um einen Spezialfall handelt, aber wir sollten diesen Fall dennoch durchspielen, um dies zu prüfen. Wir fügen der appendTester-Funktion folgenden Code hinzu:

arrayString b = new char[1];
b[0] = 0;
append(b, '!');
cout << b << "\n";

Auch das funktioniert. Nun sind wir einigermaßen sicher, dass die append-Funktion korrekt arbeitet, aber gefällt sie uns? Der Code ist schnörkellos und einen „üblen Geruch” kann ich nirgends entdecken, allerdings ist der Code für eine so einfache Operation doch ziemlich lang. Wenn ich ein wenig vorausdenke, fällt mir auf, dass die concatenate-Funktion, wie append, ebenfalls die Länge eines String-Arrays ermitteln muss, womöglich sogar von zwei String-Arrays. Da für diese Operation eine Schleife benötigt wird, die das abschließende Nullzeichen der Zeichenkette findet, können wir aus diesem Code auch eine eigene Funktion machen, die dann bei Bedarf von append oder concatenate aufgerufen wird. Die append-Funktion muss natürlich entsprechend angepasst werden:

int length(arrayString s) {
    int count = 0;
    while (s[count] != 0) {
        count++;
    }
    return count;
}
void append(arrayString& s, char c) {
    int oldLength = length(s);
    arrayString newS = new char[oldLength + 2];
    for (int i = 0; i < oldLength; i++) {
        newS[i] = s[i];
    }
    newS[oldLength] = c;
    newS[oldLength + 1] = 0;
    delete[] s;
    s = newS;
}

Der Code der length-Funktion ist im Wesentlichen derselbe Code, mit dem die append-Funktion ursprünglich begann. In der append-Funktion selbst haben wir diesen Code durch einen Aufruf der length-Funktion ersetzt. Die length-Funktion ist ein typisches Beispiel für eine Hilfsfunktion, nämlich eine Funktion, die eine Operation abkapselt, die mehreren anderen Funktionen gemeinsam ist. Dadurch wird der Code nicht nur kürzer, sondern auch zuverlässiger und leichter zu ändern, da es den redundanten Code nicht mehr gibt. Darüber hinaus hilft es auch beim Lösen einer Aufgabenstellung, weil unser Code durch Hilfsfunktionen in kleinere Bröckchen aufgeteilt wird, was es uns erleichtert, Möglichkeiten zur Wiederverwendung von Code zu erkennen.

Kopieren dynamisch allozierter Zeichenketten

Jetzt nehmen wir uns der concatenate-Funktion an. Wir verwenden denselben Ansatz wie bei der append-Funktion. Zunächst werden wir nur ein Skelett der Funktion schreiben, damit wir schon mal die Parameter und deren Typ im Kopf haben. Dann erstellen wir ein Diagramm eines Fallbeispiels und schreiben schließlich den Code, der sich aus dem Diagramm ergibt. Hier ist dieses Skelett und zusätzlich etwas Code zum Testen:

void concatenate(arrayString& s1 , arrayString s2 ) {
 }
void concatenateTester() {
    arrayString a = new char[5];
    a[0] = 't'; a[1] = 'e'; a[2] = 's'; a[3] = 't'; a[4] = 0;
    arrayString b = new char[4];
    b[0] = 'b'; b[1] = 'e'; b[2] = 'd'; b[3] = 0;
    concatenate(a, b);
}

In der Aufgabenstellung heißt es, dass die Zeichen der zweiten Zeichenkette (der zweite Parameter) ans Ende der ersten Zeichenkette angehängt werden sollen. Der erste Parameter der concatenate-Funktion ist daher aus dem gleichen Grund ein Referenzparameter wie der erste Parameter der append-Funktion. Der zweite Parameter jedoch sollte von der Funktion nicht verändert werden und wird nur als Wert übergeben. Nun zu unserem Fallbeispiel: Wir verknüpfen die beiden Zeichenketten test und bed. Abbildung 4.7 zeigt das dazugehörige Diagramm.

Abb. 4.7: Der Zustand vor (a) und nach (b) Aufruf der concatenate-Funktion

Die Einzelheiten des Diagramms sollten Ihnen vom Diagramm der append-Funktion her bekannt vorkommen. Zunächst wird für die beiden Arrays auf dem Heap dynamisch Speicherplatz reserviert, auf den die beiden Parameter s1 und s2 verweisen. Wenn die Funktion abgearbeitet ist, wird s1 auf ein neues Array im Heap verweisen, das acht Zeichen lang ist. Das Array, auf das s1 ursprünglich verwies, ist zu diesem Zeitpunkt wieder freigegeben worden, während s2 und das dazugehörige Array unverändert bleiben. Es mag unsinnig erscheinen, im Diagramm auch s2 und das bed-Array darzustellen, aber wenn man versucht, Programmierfehler zu vermeiden, ist es ebenso wichtig, die Dinge im Auge zu behalten, die sich nicht ändern, wie diejenigen, bei denen das der Fall ist. Außerdem sind die Elemente des alten und neuen Arrays nummeriert, denn das hat sich schon bei der append-Funktion als praktisch erwiesen. Da so weit alles in Ordnung ist, können wir diese Funktion nun schreiben.

void concatenate(arrayString& s1, arrayString s2) {
    int s1_OldLength = length(s1);
    int s2_Length = length(s2);
    int s1_NewLength = s1_OldLength + s2_Length;
    arrayString newS = new char[s1_NewLength + 1];
    for(int i = 0; i < s1_OldLength; i++) {
        newS[i] = s1[i];
    }
    for(int i = 0; i < s2_Length; i++) {
        newS[s1_OldLength + i] = s2[i];
    }
   newS[s1_NewLength] = 0;
   delete[] s1;
   s1 = newS;
}

Zunächst ermitteln wir die Länge der beider Zeichenketten, die verknüpft werden sollen , und summieren diese Werte, um die Länge des verknüpften Strings zu erhalten. Diese Werte gelten immer nur für die Anzahl der gültigen Zeichen, ohne das Nullzeichen am Ende. Wenn wir auf dem Heap Speicherplatz für das neue Array reservieren , müssen wir das berücksichtigen und ein Element mehr als die Summe der beiden Längen reservieren, um einen Platz für das Nullzeichen zu erhalten. Dann kopieren wir die Zeichen der beiden ursprünglichen Zeichenketten in den neuen String . Die erste Schleife ist unkompliziert, aber beachten Sie die Berechnung der Indizes in der zweiten Schleife . Wir kopieren hier vom Anfang der Zeichenkette s2 in die Mitte der Zeichenkette newS. Dies ist ein weiteres Beispiel für eine Übertragung von einem Wertebereich in einen anderen, ein Verfahren, das Ihnen bereits seit Kapitel 2 bekannt ist. Das Betrachten des Diagramms erlaubt es mir, abzulesen, wie die Variablen zur Berechnung des korrekten Index im Zielstring zusammengesetzt werden müssen. Der verbleibende Code platziert das Nullzeichen ans Ende der neuen Zeichenkette . Wie bei append geben wir den Heapspeicher wieder frei, auf den der erste Parameter ursprünglich verwiesen hat , und sorgen dafür, dass dieser nun auf den neuen String verweist .

Der Code scheint zu funktionieren, aber wie vorhin sollten wir sichergehen, dass die Funktion nicht nur in unserem Fallbeispiel korrekt arbeitet, sondern in allen Fällen. Der problematischste Fall dürfte auftreten, wenn beide Zeichenketten leer sind, also nur aus dem Nullzeichen bestehen. Vor dem Fortfahren sollten wir dies explizit testen. Wenn Sie Code auf Fehlerfreiheit prüfen, der Zeiger verwendet, sollten Sie auch die Zeiger selbst im Auge behalten, nicht nur die Werte im Heap, auf die sie verweisen. Der folgende Code zeigt solch einen Testfall:

arrayString a = new char[5];
a[0] = 't'; a[1] = 'e'; a[2] = 's'; a[3] = 't'; a[4] = 0;
arrayString c = new char[1];
c[0] = 0;
concatenate(c, a);
cout << a << "\n" << c << "\n";
cout << (void *) a << "\n" << (void *) c << "\n";

Ich wollte hier sicherstellen, dass der Aufruf von concatenate zum Resultat hat, dass sowohl a als auch c auf den String test verweisen, also auf Arrays mit identischen Werten zeigen. Es ist jedoch genauso wichtig, dass sie, wie in Abbildung 4.8 (a) dargestellt, auf unterschiedliche Strings verweisen. Ich überprüfe dies mit der zweiten Ausgabeanweisung, indem ich den Typ der Variablen auf void * setze, wodurch der Ausgabestrom dazu gezwungen wird, die Rohdaten der Zeiger anzuzeigen . Hätten beide Zeiger tatsächlich denselben Wert, liegt, wie in Abbildung 4.8 (b), eine Quervernetzung vor. Geschieht dies unbeabsichtigt, kann es zu subtilen Problemen kommen, weil das Ändern einer Variablen im Heap dann auf mysteriöse Weise auch den Wert einer anderen Variablen ändert. Im Grunde ist es ein und dieselbe Variable, in größeren Programmen ist das jedoch manchmal kaum erkennbar. Wenn zwei Zeiger quervernetzt sind und Sie einen davon via delete wieder freigeben, wird der verbleibende Zeiger dadurch zu einer verwaisten Referenz. Beim Überprüfen unseres Codes müssen wir daher sehr sorgfältig vorgehen und immer auch auf potenzielle Quervernetzungen achten.

Abb. 4.8: Der Aufruf von concatenate sollte zu zwei eigenständigen Strings führen, nicht zu quervernetzten Zeigern.

Nun sind die drei Funktionen characterAt, append und concatenate implementiert und die Aufgabenstellung ist gelöst.

4.5.2 Verkettete Listen

Nun werden wir etwas Raffinierteres ausprobieren. Die Manipulation der Zeiger wird komplizierter, aber da wir inzwischen im Erstellen von Diagrammen geübt sind, können wir das Ganze deutlich vereinfachen.

Aufgabe: Nachverfolgen einer unbekannten Zahl von Schülerdatensätzen

Zur Lösung dieser Aufgabenstellung werden Sie Funktionen zum Speichern und Manipulieren von Schülerdatensätzen programmieren Diese Datensätze bestehen aus zwei Ganzahlen, nämlich der Schüler-ID und einer Note. Die folgenden Funktionen sind zu implementieren:

addRecord Diese Funktion nimmt einen Zeiger auf eine Sammlung von Schülerdatensätzen, eine Schüler-ID und eine Note entgegen und fügt der Sammlung einen neuen Datensatz mit den übergebenen Daten hinzu.

averageRecordDiese Funktion nimmt einen Zeiger auf eine Sammlung von Schülerdatensätzen entgegen und liefert den einfachen Durchschnittswert aller Noten als double zurück.

Die Anzahl der Schülerdatensätze ist beliebig. Es ist davon auszugehen, dass die addRecord-Funktion häufig aufgerufen wird. Sie muss daher möglichst effizient implementiert werden.

Es gibt eine Reihe denkbarer Ansätze, mit denen sich diese Aufgabenstellung lösen lässt, wir werden jedoch eine Methode verwenden, die uns dabei hilft, Erfahrung mit den zeigerbasierten Verfahren zur Lösung von Problemstellungen zu sammeln: verkettete Listen. Möglicherweise sind Ihnen verkettete Listen bereits begegnet, falls aber nicht, sollten Sie wissen, dass die Einführung verketteter Listen im Vergleich zu den bislang erläuterten Verfahren eine Art Umbruch darstellt. Ein guter Problemlöser hätte, ausreichend Zeit und gründliches Nachdenken vorausgesetzt, alle bisherigen Lösungen eigenständig finden können. Auf das Konzept verketteter Listen würden jedoch nur die wenigsten Programmierer von allein kommen. Sobald Sie sich allerdings damit vertraut gemacht und die Grundlagen verstanden haben, werden Ihnen alle möglichen anderen verketteten Datenstrukturen durch den Kopf gehen. Dann sind Sie für alles Weitere bereit. Eine verkettete Liste ist eine durch und durch dynamische Datenstruktur. Unsere Zeichenketten haben wir zwar in dynamisch reservierten Speicherbereichen abgelegt, aber nachdem das geschehen ist, handelt es sich um statische Gebilde, die weder wachsen noch schrumpfen, sondern höchstens ersetzt werden. Eine verkettete Liste hingegen wächst im Laufe der Zeit Stück für Stück wie eine Gänseblümchenkette.

Aufbau einer Liste von Knoten

Die einzelnen Elemente einer verketteten Liste heißen Knoten. Um eine verkettete Liste von Schülerdatensätzen aufzubauen, benötigen Sie als Knoten eine struct, die aus einem Zeiger auf dieselbe struct sowie den eigentlichen Daten, die Sie in der verketteten Liste speichern möchten, besteht. Bei der vorliegenden Aufgabenstellung brauchen wir also eine struct mit Schüler-ID und Note.

struct listNode {
    int studentNum;
    int grade;
    listNode * next;
};
typedef listNode * studentCollection;

Die Bezeichnung für die struct lautet listNode . Dass eine struct, die zum Aufbau einer verketteten Liste verwendet wird, als Knoten (englisch node) bezeichnet wird, ist vermutlich auf den aus der Botanik stammenden Begriff zurückzuführen, der den Ort bezeichnet, an dem ein neuer Ast aus einem Baumstamm sprießt. Der Knoten besteht aus der Schüler-ID und der Note, der eigentlichen „Nutzlast” des Knotens. Darüber hinaus ist ein Zeiger auf genau die struct, die wir gerade definieren, Bestandteil der struct . Die meisten Programmierer, die erstmals damit konfrontiert werden, finden das verwirrend und vermuten sogar eine syntaktische Unmöglichkeit. Wie soll es möglich sein, eine struct zu definieren, die sich auf sich selbst bezieht? Der Code ist aber in Ordnung und seine Bedeutung wird in Kürze klar werden. Beachten Sie auch, dass der selbstbezügliche Zeiger typischerweise eine Bezeichnung wie next, nextPointer oder ähnlich trägt. Schließlich deklariert der Code noch eine typedef für einen Zeiger auf einen Knoten dieses Typs . Dies steigert die Verständlichkeit beim Betrachten unserer Funktionen. Nun legen wir eine verkettete Liste unter Verwendung dieses Typs an:

studentCollection sc;
listNode * node1 = new listNode;
node1->studentNum = 1001; node1->grade = 78;
listNode * node2 = new listNode;
node2->studentNum = 1012; node2->grade = 93;
listNode * node3 = new listNode;
node3->studentNum = 1076; node3->grade = 85;
sc = node1;
node1->next = node2;
node2->next = node3;
node3->next = NULL;
node1 = node2 = node3 = NULL;

Zunächst deklarieren wir eine Sammlung von Schüler-IDs namens sc , die später als Name der verketteten Liste dienen wird. Dann deklarieren wir node1 , einen Zeiger auf listNode. studentCollection und listNode * sind zwar gleichbedeutend, aber der Verständlichkeit halber verwende ich den Typ studentCollection nur für Variablen, die auf die Listenstruktur als Ganzes verweisen. Nach der Deklaration von node1 sorgen wir dafür, dass der Zeiger auf einen reservierten Speicherbereich im Heap verweist und geben den Feldern studentNum und grade dieses Knotens Werte . Das Feld next bekommt hier noch keinen Wert zugewiesen. Es geht hier zwar nicht um Syntax, aber falls Ihnen die Notation -> unbekannt ist: Sie wird verwendet, um auf ein Feld in einer struct (oder class) zu verweisen. node1->studentNum bedeutet also „das Feld studentNum in der struct, auf die node1 verweist” und ist äquivalent zu (*node1).studentNum. Das Prozedere wiederholen wir für node2 und node3 . Nach Zuweisung der Werte zu den Feldern des letzten Knotens befindet sich der Arbeitsspeicher in dem in Abbildung 4.9 dargestellten Zustand. Die aneinandergereihten Kästchen, die früher zur Darstellung von Arrays dienten, verwenden wir in diesen Diagrammen, um die struct und deren Knoten zu repräsentieren.

Abb. 4.9: Inmitten des Aufbaus einer verketteten Liste

Nun sind alle Knoten angelegt und wir können diese miteinander verknüpfen, um eine verkettete Liste zu bilden. Genau das erledigt der restliche Code im Listing. Wir sorgen dafür, dass die Variable studentCollection auf den ersten Knoten zeigt und dass die next-Felder des ersten und zweiten Knotens auf den zweiten bzw. dritten Knoten verweisen. Im nächsten Schritt weisen wir dem next-Feld des dritten Knotens den Wert NULL zu (NULL ist, wie erwähnt, nur ein anderer Ausdruck für 0). Wir tun das aus dem gleichen Grund, aus dem wir in der vorhergehenden Aufgabenstellung am Ende der Arrays ein Nullzeichen abgelegt haben, nämlich um das Ende der Datenstruktur zu kennzeichnen. Ebenso wie zur Markierung des Endes eines Arrays benötigen wir ein Hinweiszeichen im next-Feld des letzten Knotens der verketteten Liste, anhand dessen wir erkennen können, dass es sich überhaupt um den letzten Knoten handelt. Schließlich räumen wir auf und gehen potenziellen Problemen durch Quervernetzung aus dem Weg, indem wir den einzelnen Knotenzeigern NULL zuweisen . Den daraus resultierenden Zustand des Arbeitsspeichers zeigt Abbildung 4.10.

Abb. 4.10: Die fertige verkettete Liste

Mit diesem Diagramm vor Augen wird klar, warum die Struktur als verkettete Liste bezeichnet wird: Jeder Knoten in der Liste ist mit dem nächsten verbunden oder eben „verkettet”. Oft werden die Knoten verketteter Listen nebeneinander abgebildet, jedoch bevorzuge ich die im Diagramm verwendete Darstellungsweise, bei der erkennbar ist, dass die Knoten über den Speicherbereich verteilt sind, weil dadurch betont wird, dass die einzelnen Knoten keine Beziehung zueinander haben – wenn man von den Verbindungen einmal absieht. Jeder der Knoten könnte sich an einem beliebigen Ort im Heap befinden. Gehen Sie den Code schrittweise durch, bis Sie zur Überzeugung gelangen, dass er tatsächlich zu dem im Diagramm dargestellten Zustand führt.

Beachten Sie, dass im Endzustand nur noch ein stackbasierter Zeiger in Gebrauch ist, nämlich die studentCollection-Variable sc, die auf den ersten Knoten verweist. Einen externen Zeiger auf die Liste (nicht also das next-Feld eines Knotens) nennen wir den Startzeiger der Liste. Auf symbolischer Ebene repräsentiert diese Variable die gesamte Liste, verweist direkt aber natürlich nur auf den ersten Knoten. Um zum zweiten Knoten zu gelangen, benötigen wir den ersten, und um zum dritten Knoten zu gelangen, sowohl den ersten als auch den zweiten usw. Daraus folgt, dass verkettete Listen nur sequenziellen Zugriff erlauben, im Gegensatz zu Arrays, bei denen wahlfreier Zugriff möglich ist. Dieser sequenzielle Zugriff ist die Schwäche verketteter Listen. Die große Stärke ist, wie schon angedeutet, die Möglichkeit, durch Hinzufügen oder Entfernen von Knoten die Größe der Datenstruktur ändern zu können, ohne dass es erforderlich ist, eine völlig neue Datenstruktur zu erstellen und die Daten zu kopieren, wie wir es im Fall von Arrays getan haben.

Hinzufügen von Knoten zu einer Liste

Nun wollen wir die Funktion addRecord implementieren. Diese soll einen neuen Knoten anlegen und ihn mit einer vorhandenen verketteten Liste verknüpfen. Wir werden das gleiche Verfahren verwenden, das bei der vorhergehenden Aufgabenstellung zum Einsatz kam. Zunächst erstellen wir ein Skelett der Funktion und einen Testaufruf. Wir fügen den Code dem letzten Listing hinzu, daher existiert sc bereits und ist der Startzeiger der Liste mit den drei Knoten.

void addRecord(studentCollection& sc, int stuNum, int gr) {
}
addRecord(sc, 1274, 91);

Der Aufruf gehört an das Ende des vorhergehenden Listings. Anhand der Parameter im Skelett der Funktion können wir ein Diagramm vom Zustand des Arbeitsspeichers vor dem Aufruf der Funktion erstellen (Abbildung 4.11).

Abb. 4.11: Der Zustand des Arbeitsspeicher vor Aufruf der addRecord-Funktion

Beim Diagramm des Zustands nach dem Aufruf müssen wir allerdings eine Entscheidung treffen. Wir werden auf dem Heap einen neuen Knoten erstellen und den Feldern stuNum und gr des neuen Knotens die Werte der beiden Parameter zuweisen. Die Frage ist, an welcher Position in der verketteten Liste der Knoten hinzufügt werden soll. Die naheliegendste Wahl wäre das Ende der Liste. Das next-Feld enthält hier NULL und bittet förmlich darum, den Zeiger auf den nächsten Knoten aufzunehmen. Das entspricht Abbildung 4.12.

Abb. 4.12: Ein möglicher Zustand nach Aufruf der addRecord-Funktion

Wenn wir aber annehmen dürfen, dass die Reihenfolge der Knoten keine Rolle spielt (die Knoten also nicht in der Reihenfolge bleiben müssen, in der sie der Liste hinzugefügt wurden), wäre dies keine gute Wahl. Stellen Sie sich eine Liste vor, die nicht nur drei, sondern 3.000 Schüler enthält, um zu verstehen, warum das der Fall ist. Wir müssten alle 3.000 Knoten konsultieren, um den letzten Knoten der verketteten Liste zu erreichen und dessen next-Feld zu ändern. Das ist inakzeptabel ineffizient, weil wir den neuen Knoten der Liste hinzufügen können, ohne auch nur einen einzigen der vorhandenen Knoten zu benötigen.

Abbildung 4.13 können Sie entnehmen, wie das funktioniert. Nach Aufruf der addRecord-Funktion verweist der Startzeiger sc auf den neuen Knoten und das next-Feld des neuen Knotens auf den ehemals ersten Knoten in der Liste mit der Schüler-ID 1001. Zwar wird dem next-Feld des neuen Knotens ein Wert zugewiesen, aber der einzige Zeiger, der sich ändert, ist sc. Die Werte der bereits vorhandenen Knoten bleiben unangetastet und werden noch nicht einmal begutachtet. Das Diagramm führt zu folgendem Code:

void addRecord(studentCollection& sc, int stuNum, int gr) {
    listNode * newNode = new listNode;
    newNode->studentNum = stuNum;
    newNode->grade = gr;
    newNode->next = sc;
    sc = newNode;
}

Abb. 4.13: Eine bessere Lösung für den Zustand nach Aufruf der addRecord-Funktion. Der gestrichelte Pfeil markiert den ursprünglichen Wert des in sc gespeicherten Zeigers.

An dieser Stelle möchte ich erneut unterstreichen, dass es erheblich einfacher ist, den Code mittels eines Diagramms zu erstellen, als zu versuchen, alles im Kopf zu erledigen. Der Code lässt sich unmittelbar aus der Abbildung ableiten. Wir legen einen neuen Knoten an und weisen die Funktionsparameter Schüler-ID und Note zu . Nun verknüpfen wir den neuen Knoten mit der Liste, indem wir das next-Feld des neuen Knotens auf den ehemals ersten Knoten zeigen lassen (durch Zuweisung des Werts von sc) und sorgen dann dafür, dass sc selbst auf den neuen Knoten verweist . Beachten Sie, dass die letzten beiden Schritte in der angegebenen Reihenfolge ausgeführt werden müssen, weil der ursprüngliche Wert von sc noch gebraucht wird, bevor wir sc ändern. Beachten Sie ebenfalls, dass sc als Referenzparameter übergeben werden muss, da wir dessen Wert ändern.

Nach dem Erstellen des Codes für ein Fallbeispiel müssen wir wie üblich die Spezialfälle überprüfen. Das bedeutet hier, zu testen, ob die Funktion mit einer leeren Liste korrekt arbeitet. Im Fall der String-Arrays war auch eine leere Zeichenkette gültig, da es noch immer ein Array gab, auf das verwiesen wurde, wenngleich dieses Array nur das abschließende Nullzeichen enthielt. Bei der verketteten Liste allerdings entspricht die Anzahl der Knoten derjenigen der Datensätze, und der Startzeiger einer leeren Liste besitzt den Wert NULL. Funktioniert unser Code noch, wenn der Wert des übergebenen Startzeiger NULL ist und wir versuchen, die Beispieldaten einzufügen? Abbildung 4.14 zeigt den Zustand vor dem Funktionsaufruf und den erwünschten Zustand danach.

Abb. 4.14: Die Zustände vor und nach dem Funktionsaufruf für den Spezialfall einer leeren Liste

Beim schrittweisen Abarbeiten des Codes für dieses Fallbeispiel stellen wir fest, das es reibungslos funktioniert. Der neue Knoten wird wie zuvor erstellt. Da sc den Wert NULL besitzt, wenn dessen Wert dem next-Feld des neuen Knotens zugewiesen wird , erhält dieses ebenfalls den Wert NULL. Genau das ist erwünscht und das Ende der Liste mit nur einem Knoten ist dadurch ordnungsgemäß gekennzeichnet. Hätten wir den anderen Ansatz weiterverfolgt, den neuen Knoten am Ende der Liste hinzuzufügen, wäre eine leere Liste ein echter Spezialfall, weil nur in eben diesem Spezialfall sc geändert wird.

Werte einer Liste durchlaufen

Wenden wir uns der averageRecord-Funktion zu! Wie zuvor erstellen wir ein Funktionsskelett und ein Diagramm sowie den unten stehenden Testcode. Nehmen Sie dabei an, dass der Testaufruf erfolgt, nachdem die Beispieldaten der Abbildung 4.10 erstellt wurden.

double averageRecord(studentCollection sc) {
}
int avg = averageRecord(sc);

Wie Sie sehen, habe ich mich (wie im Fall der Arrays im letzten Kapitel) dafür entschieden, den Durchschnittswert als Ganzzahl zu berechnen. In anderen Fällen, je nach Aufgabenstellung, mag eine Gleitkommazahl besser geeignet sein. Nun bräuchten wir eigentlich ein Diagramm des Zustands vor dem Funktionsaufruf, aber Abbildung 4.9 zeigt diesen eigentlich schon ziemlich genau. Ein Diagramm für den Zustand nach dem Funktionsaufruf ist überflüssig, weil diese Funktion den Zustand der dynamischen Datenstruktur nicht ändert, sondern nur untersucht. Wir müssen nur noch das zu erwartende Ergebnis kennen, das in diesem Fall ca. 85.3333 lautet.

Wie genau berechnen wir den Durchschnittswert? Das grundlegende Konzept ist uns durch die Erfahrungen bekannt, die wir beim Berechnen der Durchschnittswerte in einem Array gemacht haben. Wir müssen sämtliche Werte der Datensammlung summieren und dann durch die Anzahl der Werte teilen. Der entsprechende Code für ein Array zur Inspektion der einzelnen Werte verwendet eine for-Schleife von 0 bis zur Größe des Arrays -1 mit der Schleifenvariablen als Index. Eine for-Schleife können wir jedoch nicht einsetzen, da uns die Anzahl der Knoten in der verketteten Liste vorab nicht bekannt ist. Wir müssen die Liste Knoten für Knoten durchlaufen, bis wir auf ein next-Feld stoßen, dessen Wert NULL ist und damit das Ende der Liste anzeigt. Das legt eine while-Schleife nahe, wie wir sie früher bereits zur Verarbeitung von Arrays unbekannter Länge verwendet haben. Wir durchlaufen also von Anfang bis Ende sämtliche Werte der Liste, eine der grundlegenden Operationen verketteter Listen. Setzen wir diese Idee zur Lösung der Aufgabenstellung in die Tat um:

double averageRecord(studentCollection sc) {
    int count = 0;
    double sum = 0;
    listNode * loopPtr = sc;
    while (loopPtr != NULL) {
          sum += loopPtr->grade;
          count++;
          loopPtr = loopPtr->next;
    }
    double average = sum / count;
    return average;
}

Zunächst deklarieren wir die Variable count , in der die Anzahl der Knoten, denen wir in der Liste begegnen, gespeichert wird. Diese Variable stellt gleichzeitig die Anzahl der Datensätze dar, die wir am Ende zur Berechnung des Durchschnittswerts verwenden. Danach deklarieren wir die Variable sum , in der wir die Summe der Noten speichern. Es folgt die Deklaration eines listNode * namens loopPtr , mit dem wir uns schrittweise in der Liste vorarbeiten und der einer int-Schleifenvariablen der for-Schleife zur Verarbeitung von Arrays entspricht. Dieser Zeiger hält fest, wo in der verketteten Liste wir uns aktuell befinden, jedoch nicht mittels einer Position, sondern durch das Speichern eines Zeigers auf den Knoten, den wir gerade verarbeiten.

Nun beginnt das eigentliche Durchlaufen der Liste. Die Schleife wird fortgesetzt, bis wir auf den Wert NULL treffen, der das Ende der Liste signalisiert . Innerhalb der Schleife addieren wir zu sum den Wert des grade-Felds des aktuellen Knotens . Wir erhöhen den Zähler und weisen dann dem Schleifenzeiger den Wert des next-Felds des aktuellen Knotens zu . Dadurch gelangen wir beim Durchlaufen der Liste zum nächsten Knoten. Hierbei handelt es sich um den kniffligen Teil des Codes; prüfen wir daher genau, ob alles stimmt. Die Abbildung 4.15 zeigt, wie sich die Knotenvariable im Laufe der Zeit ändert. Die Punkte (a) bis (d) zeigen verschiedene Stationen während der Lebensdauer der Variablen loopPtr und woher der Wert dieser Variablen jeweils stammt. Beim Punkt (a) hat die Schleife gerade erste begonnen und loopPtr wurde soeben mit dem Wert von sc initialisiert. loopPtr verweist daher, ebenso wie sc, auf den ersten Knoten der Liste. Beim ersten Durchlaufen der Schleife wird dann zu sum der Notenwert des ersten Knotens (78) hinzuaddiert. Nun wird loopPtr der Wert des next-Felds des ersten Knotens zugewiesen und loopPtr zeigt auf den zweiten Knoten; wir gelangen zu Punkt (b). Während des zweiten Durchlaufs wird 93 zu sum addiert und loopPtr erhält den Wert des next-Felds des zweiten Knotens; dies ist Punkt (c). Beim dritten und letzten Durchlauf der Schleife fügen wir zu sum 85 hinzu und weisen loopPtr den Wert NULL des next-Felds des dritten Knotens zu. Wir sind bei Punkt (d) angekommen. Wenn wir wieder zum oberen Teil der while-Schleife gelangen, wird diese beendet, denn loopPtr ist jetzt NULL. Da count bei jedem Durchlauf erhöht wurde, besitzt die Variable den Wert 3.

Sobald die Schleife endgültig beendet ist, teilen wir sum durch count und geben das Resultat zurück .

Der Code arbeitet im Fallbeispiel einwandfrei, aber wie üblich müssen wir die potenziellen Spezialfälle überprüfen. Im Fall von Listen sind dies, wie erwähnt, insbesondere leere Listen. Wie verhält sich der Code, wenn sc zu Beginn der Funktion den Wert NULL besitzt?

Abb. 4.15: So ändert sich die lokale Variable loopPtr beim Durchlaufen der Schleife in der averageRecord-Funktion.

Sie werden es nicht glauben: Der Code fliegt uns um die Ohren! (Einer dieser Spezialfälle musste schließlich mal ein böses Ende nehmen, ansonsten würden Sie mich nicht mehr ernst nehmen.) Der Code in der Schleife zum Verarbeiten der verketteten Liste ist an sich unproblematisch. Wenn sc NULL ist, wird loopPtr mit NULL initialisiert, die Schleife wird gleich wieder beendet und sum behält den Wert 0, was immerhin vernünftig ist. Das Problem ist die Division bei der Berechnung des Durchschnittswerts . Die Variable count ist ebenfalls noch 0, sodass wir eine Division durch 0 ausführen, was entweder zu einem Programmabsturz oder zu einem sinnlosen Ergebnis führt. Um diesen Spezialfall zu umgehen, könnten wir am Ende der Funktion überprüfen, dass count nicht 0 ist, aber das Problem lässt sich viel früher vermeiden, wenn wir gleich sc überprüfen. Fügen wir also der averageRecord-Funktion die folgende neue erste Zeile hinzu:

if (sc == NULL) return 0;

Wie dieses Beispiel zeigt, ist es für gewöhnlich ziemlich einfach, Spezialfälle zu umgehen. Wir müssen uns nur die Zeit nehmen, diese auch aufzuspüren.

4.6 Fazit und Ausblick

Dieses Kapitel hat hinsichtlich der Lösung von Problemstellungen unter Verwendung von Zeigern und dynamischer Speicherverwaltung bestenfalls an der Oberfläche gekratzt. Zeiger und Speicherreservierungen auf dem Heap werden Ihnen im Rest des Buches immer wieder begegnen. Beispielsweise sind objektorientierte Techniken der Programmierung, die in Kapitel 5 noch vorgestellt werden, besonders hilfreich im Umgang mit Zeigern. Diese Verfahren ermöglichen es, Zeiger so zu verkapseln, dass wir uns um Speicherlecks, verwaiste Referenzen oder die anderen Fallstricke bei der Programmierung mit Zeigern keine Sorgen mehr machen müssen.

Es gibt zwar noch sehr viel mehr über die Lösung von Problemstellungen auf diesem Gebiet zu lernen, dennoch werden Sie in der Lage sein, Ihre Fähigkeiten hinsichtlich zeigerbasierter Datenstrukturen zunehmender Komplexität weiterzuentwickeln, wenn Sie die grundlegenden Konzepte dieses Kapitels beachten. Halten Sie sich an die allgemeinen Regeln beim Lösen von Problemstellungen. Folgen Sie dann den speziellen Vorgehensweisen bei Zeigern und verwenden Sie ein Diagramm oder ein vergleichbares Werkzeug, um jede der Lösungen zu visualisieren, bevor Sie mit dem Programmieren anfangen.

4.7 Übungen

Es ist mir ernst mit den Übungen. Sie lesen doch nicht etwa nur das Kapitel und fahren dann mit dem nächsten fort, oder?

4.1

Entwerfen Sie selbst eine Aufgabenstellung. Suchen Sie nach einer Aufgabe, die Sie mittels eines Arrays bereits lösen konnten, bei dem die Größe des Arrays jedoch beschränkt ist. Schreiben Sie den Code unter Verwendung eines dynamisch erstellten Arrays so um, dass die Beschränkung entfällt.

4.2

Schreiben Sie für die dynamisch allozierten Strings eine Funktion substring, die drei Parameter entgegennimmt: einen arrayString, eine ganzzahlige Startposition und eine ebenfalls ganzzahlige Länge. Die Funktion gibt einen Zeiger auf ein neues, dynamisch alloziertes String-Array zurück. Dieses String-Array enthält die Zeichen des ursprünglichen Strings, angefangen von der angegebenen Startposition bis zur angegebenen Länge. Der ursprüngliche String bleibt dabei unangetastet. Wenn der ursprüngliche String abcdefg ist, die Startposition 3 und die Länge 4 lautet, sollte der neue String also aus cdef bestehen.

4.3

Schreiben Sie für die dynamisch allozierten Strings eine Funktion replacestring, die drei Parameter des Typs arrayString entgegennimmt: source, target und replaceText. Die Funktion ersetzt sämtliche Vorkommen von target in source durch replaceText. Wenn beispielsweise source auf abcdabee, target auf ab und replaceText auf xyz zeigt, sollte source nach dem Funktionsaufruf auf xyzcdxyzee verweisen.

4.4

Ändern Sie die Implementierung der Strings so ab, dass an der Position [0] des Arrays die Länge des Arrays gespeichert wird (und Position [1] das erste Zeichen des eigentlichen Strings enthält), anstatt ein Nullzeichen zur Kennzeichnung am Ende der Zeichenkette zu verwenden. Implementieren Sie die drei Funktionen append, concatenate und characterAt und verwenden Sie die Information über die Länge des Strings dabei so oft wie möglich. Da das vom Standardausgabestrom erwartete Nullzeichen am Ende nun nicht mehr vorhanden ist, müssen Sie eine eigene Ausgabefunktion programmieren, die den Stringparameter in einer Schleife zeichenweise anzeigt.

4.5

Schreiben Sie eine Funktion removeRecord, die einen Zeiger auf studentCollection und eine Schüler-ID entgegennimmt und den Schülerdatensatz mit dieser Schüler-ID aus studentCollection entfernt.

4.6

Erstellen Sie eine String-Implementierung, die statt dynamisch allozierter Arrays eine verkettete Liste von Zeichen verwendet. Die „Nutzlast” der verketteten Liste ist also nur ein einzelnes Zeichen. Diese Strings können wachsen, ohne dass der gesamte String neu angelegt werden muss. Beginnen Sie mit der Implementierung der Funktionen append und characterAt.

4.7

Implementieren Sie im Anschluss an die vorhergehende Übung die concatenate-Funktion. Beachten Sie hier, dass beim Aufruf einer Funktion concatenate(s1, s2), bei dem beide Parameter Zeiger auf den ersten Knoten der zugehörigen verketteten Liste sind, Kopien der Knoten in s2 erstellt werden sollten, die dann am Ende von s1 hinzugefügt werden. Die Funktion sollte nicht einfach nur den Zeiger im next-Feld des letzten Knotens in s1 auf den ersten Knoten in s2 „umbiegen”.

4.8

Fügen Sie der String-Implementierung durch verkettete Listen eine Funktion removeChars hinzu, die einen durch Position und Länge festgelegten Abschnitt aus einem String entfernt. Wenn s1 auf abcdefg zeigt, würde removeChars(s1, 4, 2) beispielsweise de aus s1 entfernen, sodass abcfg verbleibt. Stellen Sie dabei sicher, dass der Speicherplatz der entfernen Knoten ordnungsgemäß wieder freigegeben wird.

4.9

Stellen Sie sich eine verkettete Liste vor, bei der statt eines Zeichens eine Ziffer in den Knoten gespeichert wird, nämlich eine Ganzzahl im Wertebereich von 0 bis 9. Mit solch einer Liste könnten positive Zahlen beliebiger Größe dargestellt werden. Die Zahl 149 wäre beispielsweise eine Liste, bei denen der erste Knoten eine 1, der zweite eine 4 und der dritte eine 9 speichert. Schreiben Sie eine Funktion intToList, die eine Ganzzahl entgegennimmt und solch eine Liste erzeugt. Hinweis: Es fällt Ihnen womöglich leichter, die verkettete Liste rückwärts aufzubauen, also im Fall von 149 zunächst den Knoten mit der 9 zu erstellen.

4.10

Schreiben Sie eine Funktion, die zwei der Listen der vorhergehenden Übung entgegennimmt und eine neue Liste mit der Summe der übergebenen Zahlen erzeugt.

Kapitel 5: Klassen

Dieses Kapitel hat Klassen und objektorientierte Programmierung zum Thema. Es wird vorausgesetzt, dass Sie die class-Deklaration in C++ kennen und Ihnen die grundlegende Syntax einer Klasse, der Aufruf von Methoden einer Klasse usw. geläufig sind. Im nächsten Abschnitt folgt ein kurzer Überblick, wir werden uns jedoch vornehmlich mit den verschiedenen Aspekten von Klassen bei der Lösung von Problemstellungen beschäftigen.

Ich denke, dass es sich hier wieder um eine der Situationen handelt, in der C++ anderen Programmiersprachen gegenüber im Vorteil ist. Da C++ eine Hybridsprache (prozedural und objektorientiert) ist, steht es einem C++-Programmierer frei, Klassen zu erstellen, wenn es angebracht ist, er ist jedoch nie dazu gezwungen. Im Gegensatz dazu muss sich der Code in Programmiersprachen wie Java oder C# innerhalb der engen Grenzen einer Klassendeklaration bewegen. Für erfahrene Programmierer stellt dies keine ungebührliche Belastung dar, bei Einsteigern kann es jedoch zu schlechten Angewohnheiten führen. Für einen Java- oder C#-Programmierer ist alles ein Objekt. Der in diesen Sprachen geschriebene Code muss zwar immer durch Objekte gekapselt sein, er spiegelt aber oft kein vernünftiges objektorientiertes Design wider. Ein Objekt ist eine sinnstiftende, eng miteinander verknüpfte Zusammenstellung von Daten und Code, wobei der Code die Daten handhabt oder bearbeitet. Eine Wundertüte voller beliebiger Überbleibsel ist ein Objekt jedenfalls nicht.

Da wir in C++ programmieren und somit die Wahl zwischen prozeduraler und objektorientierter Programmierung haben, wird sowohl vernünftiges Klassendesign als auch die Frage, wann Klassen angemessen sind (und wann nicht), zur Sprache kommen. Das Erkennen von Situationen, in der eine Klasse nützlich wäre, ist für das Erreichen der „höheren Sphären” des Programmierstils unverzichtbar, es ist aber genauso wichtig, Umstände zu erkennen, in denen der Gebrauch von Klassen die Lage verschlimmern würde.

5.1 Klassen-Grundlagen

Sie sollten, wie immer in diesem Buch, über elementare Kenntnisse der C++-Syntax verfügen. Um sicherzustellen, dass dies der Fall ist und damit auch die Terminologie klar ist, folgt ein kurzer Überblick über die Klassensyntax. Eine Klasse ist eine Art Bauplan zur Konstruktion einer Kombination aus Code und Daten. Jedes anhand dieses Bauplans erstellte Objekt ist eine Instanz dieser Klasse. Code außerhalb der Klasse, der Instanzen dieser Klasse erstellt und verwendet, ist ein Client dieser Klasse. Die Klassendeklaration benennt die Klasse und führt alle Elemente oder Member der Klasse auf. Ein solches Element ist entweder eine Membervariable (eine Variable, die innerhalb der Klasse deklariert wird) oder eine Methode (oft auch als Memberfunktion bezeichnet), also eine Funktion, die innerhalb der Klasse deklariert wird. Memberfunktionen können einen Konstruktor besitzen, der denselben Namen wie die Klasse besitzt und beim Erstellen einer Instanz der Klasse automatisch aufgerufen wird. Neben den üblichen Attributen einer Variablen- oder Funktionsdeklaration (wie Datentyp oder bei Funktionen Parameterliste) gibt es Schlüsselwörter für die Zugriffkontrolle, die festlegen, welche Funktionen auf die Methode zugreifen dürfen. Ist ein Member public (öffentlich), darf jeglicher Code darauf zugreifen: Code innerhalb der Klasse, ein Client der Klasse oder Code in einer abgeleiteten Klasse (Unterklasse), die Code und Daten der Basisklasse „erbt”. Ist ein Member private (privat), darf nur Code innerhalb der Klasse darauf zugreifen. Ist ein Member hingegen protected (geschützt), verhält es sich wie bei einem private-Member, allerdings dürfen abgeleitete Klassen darauf zugreifen. (protected-Membern werden Sie in Kürze in diesem Kapitel begegnen.) Code eines Clients darf jedoch weder auf private- noch auf protected-Member zugreifen. Das Schlüsselwort zur Festlegung des Zugriffs bleibt innerhalb der Klassendeklaration gültig, bis es durch ein anderes ersetzt wird. Daher kommt jedes Schlüsselwort für gewöhnlich nur ein Mal vor und die Member werden anhand der Zugriffsrechte gruppiert. Man spricht dann vom „privaten” oder „öffentlichen Abschnitt” und sagt etwas wie: „Wir sollten diese Methode in den privaten Abschnitt verschieben.”

Betrachten wir ein kurzes Beispiel für eine Klassendeklaration:

class sample {
public:
    sample();
    sample(int num);
    int doesSomething(double param);
private:
    int intData;
};

Die Deklaration beginnt mit dem Namen der Klasse , sodass sample nach Abschluss der Deklaration eine Typbezeichnung ist. Es folgt das Schlüsselwort public zur Festlegung des Zugriffs . Bis zum nächsten Schlüsselwort private ist also alles Folgende öffentlich. Viele Programmierer platzieren den öffentlichen Abschnitt am Anfang des Codes, weil sie davon ausgehen, dass die öffentlich zugängliche Schnittstelle der Klasse für andere Leser am interessantesten ist. Bei den öffentlichen Deklarationen handelt es sich hier um zwei Konstruktoren ( und ) namens sample sowie um eine weitere Methode namens doesSomething . Die Konstruktoren werden automatisch aufgerufen, wenn eine Instanz dieser Klasse erzeugt wird.

sample object1;
sample object2(15);

object1 würde hier den ersten Konstruktor aufrufen, den sogenannten Standardkonstruktor, der keine Parameter besitzt. Hingegen würde object2 den zweiten Konstruktor aufrufen, weil hier eine einzelne Ganzzahl übergeben wird, was mit der Parametersignatur des zweiten Konstruktors übereinstimmt.

Die Deklaration endet mit der privaten Membervariablen intData . Beachten Sie, dass eine Klassendeklaration durch eine schließende geschweifte Klammer und ein Semikolon abgeschlossen wird. Dieses Semikolon erscheint ein wenig rätselhaft, denn Funktionen, Codeblöcken in if-Anweisungen oder anderen schließenden geschweiften Klammern folgt sonst ja auch kein Semikolon. Dieses Semikolon ist tatsächlich ein Hinweis darauf, dass Klassendeklarationen optional auch Objektdeklarationen sind. Wir könnten zwischen der schließenden geschweiften Klammer und dem Semikolon Bezeichner einfügen und beim Erstellen von Klassen auch Objekte erzeugen. Allerdings kommt dies in C++ kaum vor, schon gar nicht angesichts der Tatsache, dass viele Programmierer ihre Klassendeklaration in vom Hauptprogramm getrennten Dateien ablegen. Dieses rätselhafte Semikolon erscheint übrigens ebenfalls hinter der schließenden geschweiften Klammer einer struct.

Wenn wir schon beim Thema struct sind, sollten Sie wissen, dass struct und class in C++ fast dasselbe bezeichnen. Der einzige Unterschied betrifft Member (Daten oder Methoden), die vor dem Auftauchen des ersten Schlüsselworts zur Festlegung des Zugriffs deklariert werden. Im Fall von struct sind diese Member öffentlich, bei class hingegen privat. Ein guter Programmierer verwendet die beiden Gebilde jedoch unterschiedlich. Das ist analog zu dem Phänomen, dass sich jede for-Schleife auch als while-Schleife formulieren lässt, ein guter Programmierer jedoch besser verständlichen Code erstellt, indem er für besonders geradlinige Abzählungen for-Schleifen benutzt. Die meisten Programmierer verwenden struct für besonders einfache Strukturen, die entweder (außer in den Konstruktoren) keine Membervariablen besitzen oder die dazu gedacht sind, als Parameter für Methoden einer größeren Klasse zu dienen.

5.2 Ziele bei der Verwendung von Klassen

Um die für die Verwendung von Klassen geeigneten Situationen und die korrekte Methode zum Erstellen einer Klasse zu erkennen, müssen wir herausfinden, welche Ziele wir bei der Verwendung von Klassen überhaupt verfolgen. Dabei sollten wir daran denken, dass Klassen nur eine Option sind. Das soll heißen, dass Klassen uns keine neuen Möglichkeiten in der Art und Weise eröffnen, wie ein Array oder eine zeigerbasierte Datenstruktur dies tun. Ein Programm, das ein Array verwendet, um 10.000 Datensätze zu sortieren, könnte ohne Array nicht geschrieben werden. Wenn ein Programm darauf beruht, dass verkettete Listen im Laufe der Zeit wachsen und schrumpfen können, wären Sie außerstande, ein vergleichbar effizientes Programm ohne Verwendung verketteter Listen oder einer ähnlichen, zeigerbasierten Datenstruktur zu schreiben. Wenn Sie hingegen aus einem objektorientierten Programm die Klassen entfernen und es umschreiben, wird das Programm zwar anders aussehen, aber seine Leistungsfähigkeit und Effizienz wird nicht darunter leiden. Tatsächlich wurden die ersten C++-Compiler in Form von Präprozessoren realisiert. Diese Compiler lasen C++-Quellcode und gaben in Echtzeit Code in gültiger C-Syntax aus. Der modifizierte Quellcode wurde dann einem C-Compiler übergeben. Wir können daraus lernen, dass die entscheidenden Erweiterungen, die C++ der Sprache C hinzufügt, nicht die Funktionalität der Sprache betreffen, sondern wie sich dem Programmierer der Quellcode darstellt.

Bei der Auswahl der allgemeinen Ziele beim Klassendesign legen wir daher Wert darauf, dass diese Ziele uns Programmierern dabei helfen, unsere Aufgaben zu erfüllen. Wir sollten insbesondere darüber nachdenken, wie Klassen uns beim Lösen von Aufgabenstellungen helfen können, denn darum geht es schließlich in diesem Buch.

5.2.1 Verkapselung

Verkapselung ist eigentlich nur ein hochtrabender Begriff dafür, dass bei einer Klasse Daten und Code zu einem einzigen Paket zusammengeschnürt sind. Kennen Sie diese Sorte Pillen, die aus Gelatinekapseln bestehen und mit kleinen Kügelchen gefüllt sind? Das ist eine passende Analogie: Der Patient schluckt die Pille – und damit auch all die verschiedenen kleinen Kügelchen.

Die Verkapselung ist derjenige Mechanismus, der es uns erlaubt, viele der unten aufgeführten anderen Ziele zu erreichen, bietet aber selbst auch den Vorteil, dass unser Code dadurch organisiert wird. Bei sehr langem, rein prozeduralem Code (in C++ hieße das: nur Funktionen, keine Klassen) kann es ziemlich schwierig sein, eine vernünftige Reihenfolge für die Funktionen und Compiler-Direktiven zu finden, die wir uns leicht merken können. Stattdessen sind wir gezwungen, uns bei der Suche nach den Funktionen auf unsere Entwicklungsumgebung zu verlassen. Die Verkapselung sorgt dafür, dass Dinge, die zusammengehören, auch zusammenbleiben. Wenn Sie an einer Klassenmethode arbeiten und feststellen, dass Sie anderen Code nachlesen oder ändern müssen, ist es wahrscheinlich, dass der fragliche Code Teil einer Methode derselben Klasse ist und sich daher ganz in der Nähe befindet.

5.2.2 Wiederverwendung von Code

Beim Lösen von aktuellen Aufgabenstellungen hilft uns die Verkapselung, indem sie eine einfachere Wiederverwendung des Codes bereits gelöster Probleme erlaubt. Auch wenn wir schon an Problemen gearbeitet haben, die dem vorliegenden Projekt ähneln, bereitet die Wiederverwendung des früher Erlernten oft dennoch eine Menge Arbeit. Eine vollständig gekapselte Klasse kann fast wie ein externes USB-Laufwerk verwendet werden: anschließen (bzw. Code kopieren) und es funktioniert. Damit das wirklich so reibungslos vonstattengeht, müssen wir die Klasse allerdings richtig entwerfen und sicherstellen, dass Code und Daten auch wirklich voneinander getrennt sind und so unabhängig wie möglich von Code und Daten außerhalb der Klasse sind. Eine Klasse, die eine globale Variable verwendet, kann beispielsweise nicht in ein anderes Projekt kopiert werden, ohne auch die globale Variable zu kopieren.

Neben der Möglichkeit, sie in anderen Programmen wiederzuverwenden, bieten Klassen auch das Potenzial für eine direktere Form der Wiederverwendung von Code: Vererbung. In Kapitel 4 kamen Hilfsfunktionen vor, die dazu dienen, Code „auszulagern”, der mehreren Funktionen gemein ist. Bei der Vererbung wird diese Idee in größerem Maßstab umgesetzt. Dank der Vererbung können wir Basisklassen erstellen, deren Methoden mehreren abgeleiteten Klassen gemein sind, und auf diese Weise nicht nur ein paar Zeilen Code, sondern ganze Methoden „auslagern”. Vererbung ist jedoch ein Thema für sich, und wir werden diese Form der Wiederverwendung von Code später in diesem Kapitel genauer untersuchen.

5.2.3 Zerlegung in Teilaufgaben

Eine der Vorgehensweisen, auf die wir immer wieder zurückgegriffen haben, ist die Zerlegung eines komplizierten Problems in Teilaufgaben, die leichter zu handhaben sind. Klassen sind hervorragend dazu geeignet, Programme in funktionale Einheiten zu zerlegen. Die Verkapselung hält nicht nur Code und Daten in wiederverwendbaren Paketen zusammen, sondern riegelt Code und Daten vom Rest des Programms ab, was es uns gestattet, getrennt an dieser Klasse (und anderen Dingen) zu arbeiten. Je mehr Klassen wir in einem Programm erstellen, umso größer ist der Effekt der Zerlegung in Teilaufgaben.

Wir sollten also, sofern möglich, Klassen zur Zerlegung komplizierter Aufgabenstellungen verwenden. Sind die Klassen sorgfältig designt, fördert dies außerdem die Aufteilung in mehrere einzelne Funktionen, und die Aufgabenstellung ist leichter lösbar. Angenehmer Nebeneffekt hierbei: Sie werden möglicherweise feststellen, dass die für eine bestimmte Aufgabe erstellten Klassen sich bei einer anderen Problemstellung wiederverwenden lassen, obwohl diese Möglichkeit beim Erstellen der Klasse gar nicht ausdrücklich berücksichtigt wurde.

5.2.4 Information Hiding

Manche Autoren verwenden die Begriffe Verkapselung und Information Hiding synonym, hier treffen wir jedoch eine Unterscheidung. Wie früher in diesem Kapitel erwähnt, bedeutet Verkapselung, Daten und Code zu einem einzigen Paket zusammenzuschnüren. Beim Information Hiding geht es darum, die Schnittstelle einer Datenstruktur (die Definition der möglichen Operationen und deren Parameter) von der Implementierung der Datenstruktur (dem Code in den Funktionen) zu trennen. Wenn eine Klasse mit dem Ziel programmiert wird, Information Hiding umzusetzen, dann ist es möglich, die Implementierung der Methoden zu ändern, ohne dass irgendwelche Änderungen am Client-Code (Code, der die Klasse verwendet) erforderlich sind. Hier muss noch mal die Bedeutung des Begriffs Schnittstelle klargestellt werden. Damit sind nicht nur die Namen der Methoden und deren Parameterlisten gemeint, sondern auch die Erklärungen (die vielleicht in der Dokumentation des Codes zu finden sind), was genau die verschiedenen Methoden leisten. Wenn also vom Ändern der Implementierung ohne Änderung der Schnittstelle gesprochen wird, ist damit gemeint, dass wir ändern, wie die Klassenmethoden funktionieren, nicht aber, was diese tun. Manche Autoren beschreiben das als eine Art stillschweigenden Vertrag zwischen der Klasse und dem Client: Die Klasse stimmt zu, die Resultate der vorhandenen Operationen niemals zu ändern, und der Client sagt zu, die Klasse streng nach Vorschrift der Schnittstelle zu verwenden und sich nicht um die Details der Implementierung zu kümmern. Stellen Sie sich eine Universalfernbedienung vor, mit der sich jeder beliebige Fernseher fernbedienen lässt, sei es nun ein altes Röhrengerät, einer mit LCD oder ein Plasmabildschirm. Wenn Sie auf der Fernbedienung nun die Tasten 2 und 5 drücken, zeigen alle Fernseher das gleiche Fernsehprogramm auf Programmplatz 25 an, obwohl die zugrunde liegende Technologie, die das bewirkt, jeweils eine völlig andere ist.

Ohne Verkapselung gibt es auch kein Information Hiding, aber so, wie wir die Begriffe definiert haben, ist Verkapselung ohne Information Hiding durchaus möglich. Dieser Fall tritt offensichtlich ein, wenn die Membervariablen der Klasse als public deklariert werden. Die Klasse ist dann noch immer eine Verkapselung, da sie Code und Daten, die zusammengehören, zu einem Paket bündelt. Allerdings hat der Client-Code nun Zugriff auf ein wichtiges Detail der Implementierung der Klasse, nämlich die Variablen und Datentypen, die von der Klasse zum Speichern ihrer Daten verwendet werden. Selbst wenn der Client-Code die Daten der Klasse nicht ändert und nur lesend darauf zugreift, benötigt er doch genau diese bestimmte Implementierung der Klasse. Jede Änderung an der Klasse, die Name oder Typ der Variablen betrifft, auf die der Client-Code zugreift, macht auch eine Änderung des Client-Codes erforderlich.

Vielleicht ist Ihnen der Gedanke gekommen, dass Information Hiding garantiert ist, wenn nur alle Daten als private deklariert werden und genügend Zeit darauf verwendet wird, die Methoden und deren Parameterlisten so zu vervollkommnen, dass niemals eine Änderung notwendig ist. All das ist zwar für das Information Hiding notwendig, jedoch nicht hinreichend, da die hier auftretenden Schwierigkeiten subtiler sein können. Denken Sie daran, dass die Klasse es garantieren muss, dass die Resultate der Methoden sich nicht ändern dürfen, egal was passiert. In den vorhergehenden Kapiteln mussten wir entscheiden, welche Sonderfälle eine Funktion handhaben soll, wie etwa beim Berechnen des Durchschnittswerts eines Arrays, wenn der Parameter, der die Größe des Arrays enthält, 0 ist. Das Ändern des Resultats einer Methode, selbst in Spezialfällen, stellt eine Änderung der Schnittstelle dar und sollte vermieden werden. Dies ist ein weiterer Grund dafür, warum es beim Programmieren so wichtig ist, Spezialfälle explizit zu überprüfen. Schon so manches Programm wurde durch die Aktualisierung der zugrunde liegende Technologie oder der API unbrauchbar, nur weil der Aufruf irgendeiner Systemroutine, der vorher zuverlässig -1 zurücklieferte, wenn einer der Parameter fehlerhaft war, plötzlich eine scheinbar wahllose negative Zahl zurückgibt. Die Resultate für Spezialfälle in der Dokumentation der Klasse oder Methode aufzuführen, ist eine der besten Möglichkeiten, dieses Problem zu verhindern. Wenn Ihre eigene Dokumentation besagt, dass unter bestimmten Umständen der Fehlercode -1 zurückgeliefert wird, werden Sie es sich zwei Mal überlegen, Ihre Methode irgendetwas anderes zurückgeben zu lassen.

Welchen Einfluss aber hat Information Hiding auf das Lösen einer Aufgabenstellung? Das Prinzip des Information Hidings besagt, dass Programmierer bei der Arbeit an Client-Code die Details der Implementierung der Klasse zu ignorieren haben, oder etwas allgemeiner, dass die Implementierung einer bestimmten Klasse nur dann zur Debatte steht, wenn an eben dieser Klasse selbst gearbeitet wird. Wenn Sie die Details der Implementierung aus Ihren Gedanken verdrängen, entfallen auch ablenkende Überlegungen, und Sie können sich auf die Lösung des vorliegenden Problems konzentrieren.

Es sollte Ihnen jedoch bewusst sein, dass Information Hiding hinsichtlich des Lösens von Problemstellungen auch an Grenzen stößt. Manchmal sind die Details der Implementierung eben doch von Bedeutung für den Client. In den vorhergehenden Kapiteln haben Sie die Stärken und Schwächen einiger auf Arrays bzw. Zeigern beruhenden Datenstrukturen kennengelernt. Die auf Arrays beruhenden Datenstrukturen erlauben zwar wahlfreien Zugriff, eine Änderung der Größe ist jedoch nur umständlich möglich. Zeigerbasierte Datenstrukturen hingegen erlauben nur sequenziellen Zugriff, können aber bei Bedarf wachsen oder schrumpfen, ohne dass die Datenstruktur komplett neu erstellt werden muss. Eine Klasse, die auf Grundlage einer Array-artigen Datenstruktur programmiert wird, besitzt daher andere Eigenschaften als eine, die auf einer zeigerbasierten Datenstruktur beruht.

In der Informatik ist oft von einem abstrakten Datentyp die Rede, der Information Hiding in Reinkultur darstellt: ein Datentyp, der nur durch die damit möglichen Operationen definiert ist. In Kapitel 4 wurde das Konzept eines Stacks betrachtet und erläutert, dass der Stack eines Programms ein zusammenhängender Bereich des Arbeitsspeichers ist. Als abstrakter Datentyp ist jedoch jeder beliebige Datentyp ein Stack, wenn er es erlaubt, einzelne Objekte hinzuzufügen oder zu entfernen, und diese in der umgekehrten Reihenfolge entfernt werden, in der sie hinzugefügt wurden. Diese Anordnung wird als LIFO (Last In – First Out, zuletzt herein – zuerst hinaus) bezeichnet. Es gibt keinen Grund dafür, dass ein Stack aus einem zusammenhängenden Speicherbereich bestehen müsste, und wir könnten einen Stack unter Verwendung einer verketteten Liste programmieren. Da ein zusammenhängender Speicherbereich und eine verkettete Liste ganz verschiedene Eigenschaften besitzen, wird auch ein Stack, der Ersteren verwendet, andere Eigenschaften besitzen als ein Stack, der Letztere nutzt. Für den Client, der den Stack verwendet, kann dies einen großen Unterschied bedeuten.

Entscheidend ist letzten Endes, dass Information Hiding beim Lösen von Problemstellungen ein erstrebenswertes Ziel ist, sofern es uns ermöglicht, diese Problemstellung in Teilaufgaben zu zerlegen und getrennt an verschiedenen Teilen eines Programms zu arbeiten. Die Details der Implementierung dürfen wir dabei allerdings nicht vollkommen ignorieren.

5.2.5 Verständlichkeit

Eine gute Klasse macht das Programm, in der sie vorkommt, besser verständlich. Manche Objekte geben unsere Sichtweise der realen Welt wieder, und daher lesen sich die Bezeichnungen der Methodenaufrufe oft fast wie natürliche Sprache. Häufig ist auch die Beziehung zwischen Objekten einleuchtender als die Beziehung zwischen einzelnen Variablen. Die Erhöhung der Verständlichkeit steigert auch unsere Fähigkeit, Aufgabenstellungen zu lösen, weil wir den in Entwicklung befindlichen Code besser verstehen und weil die Wiederverwendung erleichtert wird, wenn die Arbeitsweise vorhandenen Codes gut nachvollziehbar ist.

Wir müssen uns ein paar Gedanken darüber machen, wie die Methoden einer Klasse in der Praxis verwendet werden, wenn wir einen möglichst großen Vorteil aus der Verständlichkeit von Klassen ziehen wollen. Die Namen der Methoden sollten so gewählt werden, dass sie die ganz konkrete Bedeutung der Auswirkungen einer Methode widerspiegeln. Betrachten Sie beispielsweise eine Klasse investment, die eine Kapitalanlage repräsentiert und eine Methode zur Berechnung des zukünftigen Werts besitzt. Der Name compute (berechne) sagt nur einen Bruchteil dessen aus, was die Bezeichnung computeFutureValue (zukünftigen Wert berechnen) vermittelt. Sogar die Wahl der richtigen Wortart kann hilfreich sein. computeFutureValue ist ein Verb, FutureValue hingegen ein Substantiv. Achten Sie darauf, wie die Bezeichnungen im folgenden Beispiel verwendet werden:

double FV;
investment.computeFutureValue(FV, 2050);
if (investment.futureValue(2050) > 10000) { ...

Wenn Sie darüber nachdenken, ist die erste Version für einen alleinstehenden Aufruf sinnvoller, also eine void-Funktion, die den zukünftigen Wert mittels eines Referenzparameters an den Aufrufer zurückgibt . Die zweite Version ist sinnvoller, wenn der Aufruf in einem Ausdruck verwendet wird, der zukünftige Wert also als Ergebnis der Funktion zurückgeliefert wird .

Später in diesem Kapitel werden Sie noch speziellen Beispielen begegnen, aber der Leitgedanke bei der Maximierung der Verständlichkeit ist es, beim Programmieren der Schnittstelle einer Klasse stets den daraus resultierenden Client-Code zu berücksichtigen.

5.2.6 Ausdrucksfähigkeit

Ein weiteres Ziel vernünftigen Klassendesigns ist eine hohe Ausdrucksfähigkeit, die es erleichtert, Code zu schreiben, der die Klasse verwendet. Das Schreiben des verbleibenden Codes kann durch eine wohldurchdachte Klasse auf ähnliche Weise vereinfacht werden, wie eine vernünftige Funktion das Schreiben von Code erleichtert. Die Programmiersprache wird durch Klassen faktisch erweitert. Die Klassen werden dadurch auf höherer Ebene zu Gegenstücken der grundlegenden Funktionalitäten wie Schleifen, if-Anweisungen usw. In C++ sind selbst so zentrale Funktionalitäten wie Ein- und Ausgabe nicht Teil der Sprachsyntax, sondern werden in Form einer Reihe von Klassen bereitgestellt, die ein Programm, das diese verwendet, ausdrücklich einbinden muss. Mit Klassen lassen sich Programmieraufgaben, die früher viele Schritte erforderten, in nur wenigen Schritten erledigen, manchmal sogar in einem einzigen. Wir sollten diesem Ziel beim Lösen von Problemstellungen besondere Priorität einräumen. Fragen Sie sich immer wieder: „Auf welche Weise könnte diese Klasse dazu beitragen, dass es einfacher wird, den Rest des Programms (und künftige Programme, die diese Klasse verwenden) zu schreiben?”

5.3 Eine einfache Klasse

Da Sie nun die Ziele kennen, die Ihre Klassen anstreben sollten, ist es an der Zeit, die Theorie in die Praxis umzusetzen und einige Klassen zu erstellen. Wir werden unsere Klasse stufenweise weiterentwickeln, um die folgende Aufgabenstellung zu lösen.

Aufgabe: Notenliste

Entwerfen Sie eine Klasse (oder mehrere Klassen) für ein Programm, das eine Notenliste verwaltet. Für jeden Schüler sollen Name, Schüler-ID und die Gesamtpunktzahl im Wertebereich von 0 bis 100 gespeichert werden. Das Programm erlaubt es, Schülerdatensätze hinzuzufügen oder zu löschen und den Datensatz eines bestimmten Schülers anhand der Schüler-ID anzuzeigen, wobei die Note als Punktzahl und Schulnote ausgegeben wird. Außerdem zeigt das Programm den Notendurchschnitt der Schulklasse an. Die Zuordnung von Punkten zu Schulnoten können Sie Tabelle 5.1 entnehmen.

Punktezahl | Benotung
93–100 | A
90–92 | A-
87–89 | B+
83–86 | B
80–82 | B-
77–79 | C+
73–76 | C
70–72 | C-
67–69 | D+
60–66 | D
0–59 | F

Tabelle 5.1: Benotung nach Punkten

Zunächst sehen wir uns ein elementares Grundgerüst einer Klasse an, das als Fundament der meisten anderen Klassen dient. Danach untersuchen wir die Möglichkeiten, das Grundgerüst zu erweitern.

5.3.1 Grundgerüst einer Klasse

Am einfachsten ist es, das Grundgerüst anhand einer Beispielklasse zu untersuchen. Dazu verwenden wir die struct aus Kapitel 3 und bauen diese zu einer vollständigen Klasse aus. Die ursprüngliche struct sieht folgendermaßen aus:

struct student {
    int grade;
    int studentID;
    string name;
};

Selbst eine einfache struct bringt in dieser Form schon eine Verkapselung mit. In Kapitel 3 haben wir ein Array von Schülerdatensätzen mit dieser struct erstellt, ohne deren Verwendung wir drei verschiedene Arrays für Noten, Schüler-IDs und Namen hätten erstellen müssen – unschön! Information Hiding liefert uns diese struct jedoch eindeutig nicht. Beim Klassengrundgerüst wird Information Hiding durch die Deklaration aller Daten als private erreicht. Danach deklarieren wir dann public-Methoden, damit der Client-Code indirekt auf die Daten zugreifen und diese ändern kann.

class studentRecord {
public:
    studentRecord();
    studentRecord(int newGrade, int newID, string newName);
    int grade();
    void setGrade(int newGrade);
    int studentID();
    void setStudentID(int newID);
    string name();
    void setName(string newName);
private:
    int _grade;
    int _studentID;
    string _name;
};

Wie angekündigt ist diese Klassendeklaration in einen öffentlichen Abschnitt mit Memberfunktionen und einen privaten Abschnitt unterteilt, der dieselben Daten deklariert wie die ursprüngliche struct . Es gibt insgesamt acht Memberfunktionen: zwei Konstruktoren und danach ein Paar von Memberfunktionen für jede der Membervariablen. Beispielsweise gehören zur Membervariablen _grade die Memberfunktionen grade und setGrade . Die erste Methode wird vom Client-Code verwendet, um die Note eines bestimmten studentRecord abzufragen, während die zweite Methode dazu dient, eine neue Note im studentRecord abzuspeichern.

Methoden zum Abfragen und Speichern von Membervariablen sind derart verbreitet, dass typischerweise mit den Abkürzungen get und set (oder auch Getter und Setter) auf sie verwiesen wird. Wie Sie sehen, habe ich das Wort set in die Bezeichnungen der Methoden zum Speichern neuer Werte in den Membervariablen eingearbeitet. Viele Programmierer wären mit dem Wort get genauso verfahren und hätten beispielsweise getGrade statt grade verwendet. Warum habe ich das unterlassen? Weil ich dann für die Bezeichnung einer Funktion, die wie ein Substantiv gebraucht wird, ein Verb verwenden würde. Man könnte nun argumentieren, dass der Begriff get überall verständlich und seine Bedeutung daher so klar ist, dass diese Bedenken vernachlässigbar sind. Letzten Endes handelt es sich um eine Frage des persönlichen Geschmacks.

Auch wenn ich bisher immer schnell bei der Hand war, um auf Vorteile von C++ gegenüber anderen Programmiersprachen hinzuweisen, muss ich zugeben, dass C++ von neueren Sprachen wie C# hinsichtlich der get- und set-Methoden überholt worden ist. C# besitzt einen eingebauten Mechanismus, der Property (Eigenschaft) heißt und gleichzeitig als get- und set-Methode dient. Ist die Property erst einmal definiert, verhält sie sich wie eine Membervariable, nicht wie ein Funktionsaufruf, und der Client-Code kann problemlos darauf zugreifen. Verständlichkeit und Ausdrucksfähigkeit des Codes werden dadurch erheblich verbessert. Da es einen solchen Mechanismus in C++ nicht gibt, ist es wichtig, sich bei der Bezeichnung von Methoden für eine Namenskonvention zu entscheiden und diese konsequent zu verwenden.

Beachten Sie, dass meine Namenskonvention auch die Membervariablen umfasst, deren Bezeichnungen, anders als in der ursprünglichen struct, alle mit einem Unterstrich beginnen. Das erlaubt es mir, die get-Funktionen mit (fast) demselben Namen wie die Membervariablen, die sie abfragen, zu benennen. Außerdem sind Verweise auf Membervariablen im Code leicht erkennbar und erhöhen so die Verständlichkeit. Manche Programmierer verwenden bei Verweisen auf Membervariablen statt des vorangestellten Unterstrichs das Schlüsselwort this. Statt einer Anweisung wie

return _grade;

verwenden sie also Folgendes:

return this.grade;

Falls Ihnen das Schlüsselwort noch unbekannt war: this ist eine Referenz auf das Objekt, in dem es vorkommt. Wenn die obenstehende Anweisung in einer Klassenmethode steht und diese Methode außerdem eine lokale Variable namens grade deklariert, würde der Ausdruck this.grade auf die Membervariable verweisen, nicht auf die gleichnamige lokale Variable. Die Verwendung des Schlüsselworts auf diese Weise bietet in Entwicklungsumgebungen mit automatischer Vervollständigung bei der Texteingabe einen Vorteil. Man muss nur this und einen Punkt eintippen und kann dann die Membervariable aus einer Liste auswählen, wodurch Tipparbeit und potenzielle Schreibfehler vermieden werden. Entscheidend ist hier, dass beide Verfahren Verweise auf Membervariablen im Code erkennbar machen.

Die Klassendeklaration ist erledigt, wenden wir uns also der Implementierung der Methoden zu. Fangen wir mit dem ersten get/set-Paar an:

int studentRecord::grade() {
     return _grade;
}
void studentRecord::setGrade(int newGrade) {
     _grade = newGrade;
}

Dies ist die einfachste Form eines get/set-Paares. Die erste Methode grade liefert den aktuellen Wert der zugehörigen Membervariablen _grade zurück . Die zweite Methode setGrade weist der Membervariablen _grade den Wert des Parameters newGrade zu . Wäre dies alles, was die Klasse leistet, hätten wir noch nichts erreicht. Auch wenn dieser Code Information Hiding bietet, da er Daten in beide Richtungen weitergibt, ohne diese näher zu betrachten oder zu modifizieren, ist er nur besser als eine öffentliche Deklaration von _grade, weil uns das Recht vorbehalten bleibt, Name oder Typ der Membervariablen zu ändern. Die setGrade-Methode sollte zumindest ansatzweise eine Überprüfung durchführen und eine Zuweisung an die Membervariable _grade verhindern, wenn newGrade Werte enthält, die als Note keinen Sinn ergeben. Wir müssen jedoch die Aufgabenstellung genau im Auge behalten und dürfen aufgrund unserer Erfahrungen keine Annahmen über die Daten machen, ohne den Benutzer zu berücksichtigen. Es mag vernünftig erscheinen, die Noten auf den Wertebereich von 0 bis 100 zu beschränken, wäre aber kontraproduktiv, wenn eine Schule beispielsweise Extrapunkte vergibt, die Werte über 100 ermöglichen oder die Punktzahl -1 als Code verwendet, wenn ein Schüler eine Klasse verlässt. Im vorliegenden Fall gibt uns die Aufgabenstellung eine Orientierungshilfe und wir lassen dieses Wissen in die Überprüfung einfließen:

void studentRecord::setGrade(int newGrade) {
    if ((newGrade >= 0) && (newGrade <= 100))
        _grade = newGrade;
}

Diese Überprüfung ist hier nur eine Art Pförtner. Je nach Aufgabenstellung könnte es sinnvoll sein, eine Fehlermeldung zu erzeugen, diese in einem Fehlerprotokoll zu speichern oder in anderer Weise auf den Fehler zu reagieren.

Die anderen get/set-Paare arbeiten genauso. Alle Schulen haben mit Sicherheit gewisse Regeln, die bei der Erstellung einer Schüler-ID zu beachten sind, die sich zur Überprüfung der ID eignen. Die Überprüfung des Schülernamens gestaltet sich da schwieriger. Wir können bestenfalls Strings mit seltsamen Zeichen wie % oder @ zurückweisen – obwohl heutzutage vielleicht nicht einmal mehr das möglich ist.

Der letzte Schritt zur Vervollständigung unserer Klasse ist die Programmierung der Konstruktoren. Das Grundgerüst besitzt bereits zwei Konstruktoren: den Standardkonstruktor, der keine Parameter besitzt und den Membervariablen vernünftige Standardwerte zuweist, sowie einen Konstruktor mit Parametern für jede der Membervariablen. Dieser zweite Konstruktor ist für die angestrebte Ausdrucksfähigkeit des Codes von Bedeutung, da er es gestattet, das Erstellen einer Instanz unserer Klasse und die Initialisierung der darin befindlichen Variablen in nur einem Schritt vorzunehmen. Nachdem Sie den Code der anderen Methoden erst einmal programmiert haben, schreibt sich der zweite Konstruktor schon fast von allein:

studentRecord::studentRecord(int newGrade, int newID, string newName) {
    setGrade(newGrade);
    setStudentID(newID);
    setName(newName);
}

Wie Sie sehen, ruft der Konstruktor lediglich die jeweilige set-Methode der verschiedenen Parameter auf. In den meisten Fällen ist dies der richtige Ansatz, denn er vermeidet doppelten Code und stellt sicher, dass der Konstruktor von der Überprüfung der Parameter in den set-Methoden profitiert.

Der Standardkonstruktor ist manchmal etwas knifflig, nicht etwa, weil der Code kompliziert ist, sondern weil es nicht immer einen Standardwert gibt, der auf der Hand liegt. Denken Sie an die Situationen, in denen eine mit dem Standardkonstruktor erstellte Instanz verwendet wird, wenn Sie Standardwerte für die Membervariablen festlegen, insbesondere dann, wenn es üblicherweise eine Standardinstanz der Klasse gibt. Daran können Sie erkennen, ob Sie den Membervariablen sinnvolle Standardwerte zuweisen sollten oder aber Werte, die signalisieren, dass die Instanz noch nicht ordnungsgemäß initialisiert ist. Stellen Sie sich beispielsweise eine Klasse vor, die verschiedene Werte repräsentiert, die wiederum eine verkettete Liste verkapseln. Es gibt tatsächlich eine verkettete Liste, die einen Standard darstellt: eine leere Liste. Wir würden also den Membervariablen Werte zuweisen, um eine gültige, wenn auch leere Liste zu erstellen. Bei unserer Beispielklasse gibt es jedoch keine sinnvolle Definition eines „Standard-Schülers”. Einer Standardinstanz von studentRecord eine gültige Schüler-ID zuzuweisen, scheidet aus, weil es zu einem Durcheinander mit einem echten studentRecord führen könnte. Wir sollten daher als Standardwert für _studentID einen offensichtlich ungültigen Wert wie -1 verwenden:

studentRecord::studentRecord() {
    setGrade(0);
    setStudentID(-1);
    setName("");
}

Die Zuweisung der Note findet durch setGrade statt, wobei der Parameter überprüft wird. Wir müssen daher einen gültigen Wert verwenden, wie 0. Da der Schüler-ID ein ungültiger Wert zugewiesen wird, lässt sich leicht feststellen, dass der Datensatz insgesamt ungültig ist, und der gültige Wert der Note spielt insofern keine Rolle. Wäre der Wert von Belang, könnten wir ihm durch direkte Zuweisung an die Membervariable _grade einen ungültigen Wert verleihen.

Das Grundgerüst der Klasse ist damit fertig. In jeder Instanz gibt es eine Gruppe privater Membervariablen, die auf Attribute derselben Instanz verweisen, nämlich die Werte des Schülerdatensatzes. Darüber hinaus gibt es Memberfunktionen mit Überprüfung der Parameter, die zur Abfrage und zum Ändern der Daten der Instanz dienen. Und schließlich gibt es zwei sinnvolle Konstruktoren. Wir haben nun ein solides Fundament für unsere Klassen. Die Frage ist jetzt, was noch zu tun bleibt.

5.3.2 Unterstützende Methoden

Eine unterstützende Methode ist eine Methode in einer Klasse, die nicht nur Daten abfragt oder abspeichert. Manche Programmierer bezeichnen diese auch als Hilfsmethoden oder Zusatzmethoden. Wie man sie auch nennt, durch diese Methoden hebt sich eine Klasse von einer einfachen Klasse, wie wir sie soeben erstellt haben, ab. Eine Reihe wohldurchdachter unterstützender Methoden macht eine Klasse oft erst wirklich nützlich.

Überlegen Sie sich, wie die Klasse verwendet werden wird, um zu ermitteln, welche unterstützenden Methoden infrage kommen. Gibt es bestimmte Tätigkeiten, die der Client-Code üblicherweise mit den Daten der Klasse ausführen wird? In unserem Fall soll das Programm, für das wir unsere Klasse ursprünglich entworfen haben, die Noten von Schülern nicht nur als Punktezahl, sondern auch als Schulnoten in Form von Buchstaben anzeigen. Schreiben wir also eine unterstützende Methode, die Schülernoten als Buchstaben zurückgibt. Zunächst fügen wir die Deklaration der Methode dem öffentlichen Abschnitt der Klassendeklaration hinzu.

string letterGrade();

Nun müssen wir diese Methode implementieren. Der numerische Wert in _grade soll gemäß Tabelle 5.1 in die entsprechende Zeichenkette konvertiert werden. Das könnten wir durch eine Reihe von if-Anweisungen erreichen, aber gibt es vielleicht einen etwas eleganteren Weg? Wenn Ihnen nun gerade „Moment mal, in Kapitel 3 haben wir doch Umsätze in Gewerbekategorien konvertiert” durch den Kopf ging: Glückwunsch, Sie haben soeben eine passende Analogie entdeckt! Wir können diesen Code durch const-Arrays zum Speichern der Schulnoten und der zugehörigen niedrigsten Punktzahlen anpassen und die Werte in einer Schleife konvertieren.

string studentRecord::letterGrade() {
    const int NUMBER_CATEGORIES = 11;
    const string GRADE_LETTER[] = {"F", "D", "D+", "C-", "C",
                               "C+", "B-", "B", "B+", "A-", "A"};
    const int LOWEST_GRADE_SCORE[] = {0, 60, 67, 70, 73,
                                  77, 80, 83, 87, 90, 93};
    int category = 0;
    while (category < NUMBER_CATEGORIES &&
       LOWEST_GRADE_SCORE[category] <= _grade)
        category++;
    return GRADE_LETTER[category - 1];
}

Diese Methode ist eine direkte Anpassung der Funktion in Kapitel 3, daher gibt es hier auch nichts weiter zu erklären. Die Anpassung des Codes bringt allerdings einige Designentscheidungen mit sich. Beachten Sie zunächst, dass wir keine neue Membervariable angelegt haben, um die Schulnote zu speichern, sondern diese bei jeder Anfrage in Echtzeit berechnen. Alternativ könnten wir eine Membervariable _letterGrade anlegen und die setGrade-Methode so umschreiben, dass sie neben _grade auch _letterGrade aktualisiert. Die letterGrade-Methode wäre eine einfache get-Methode, die den Wert der bereits berechneten Membervariablen zurückliefert.

Das Problem bei diesem Ansatz ist die Datenredundanz. Der Begriff beschreibt eine Situation, in der Daten entweder buchstäblich doppelt gespeichert werden oder aber jederzeit aus vorhandenen Daten errechnet werden können. Diese Schwierigkeit tritt vornehmlich bei Datenbanken auf, und Datenbankdesigner verwenden ausgetüftelte Verfahren, um redundante Daten in ihren Tabellen zu vermeiden. Allerdings kann Datenredundanz in allen Programmen vorkommen, wenn man unachtsam ist. Stellen Sie sich ein Programm vor, das medizinische Daten speichert, um sich die Gefahr bewusst zu machen. Das Programm speichert Alter und Geburtsdaten einer Patientengruppe. Das Geburtsdatum beinhaltet zwar mehr Informationen als das Alter, und die beiden Werte sind daher nicht äquivalent, aber dessen ungeachtet liefert uns das Alter keinerlei Information, die wir nicht aus dem Geburtsdatum erschließen können. Und wenn die beiden Werte einander widersprechen (was früher oder später passiert, wenn das Alter nicht automatisch aktualisiert wird)? Welchem der Werte sollen wir Glauben schenken? Ich fühle mich hier an die berühmte (obwohl vermutlich erfundene) Verkündigung des Kalifen Umar ibn al-Chattab erinnert, als er befahl, die Bücher der Bibliothek von Alexandria zu verbrennen. Wenn die Bücher in der Bibliothek mit dem Koran in Einklang stehen, so tat er kund, sind diese redundant und müssen nicht erhalten werden. Wenn sie aber dem Koran widersprechen, sind sie ein Übel, das vernichtet werden muss. Redundante Daten sind Ärger, der vor der Tür steht. Die einzige Rechtfertigung für redundante Daten wäre eine Erhöhung der Geschwindigkeit, wenn wir annehmen, dass Aktualisierungen von _grade selten und Aufrufe von letterGrade häufig sind. Es ist jedoch kaum vorstellbar, dass dadurch insgesamt ein signifikanter Geschwindigkeitsgewinn erzielt wird.

Die Methode lässt sich dennoch verbessern. Beim Testen der Methode ist mir ein Problem aufgefallen. Obwohl die Methode für gültige Werte von _grade korrekte Ergebnisse liefert, stürzt sie ab, wenn _grade negativ ist. Wenn die while-Schleife erreicht wird, sorgt der negative Wert von _grade dafür, dass die Schleife sofort abgebrochen wird. category bleibt daher 0 und die return-Anweisung versucht, GRADE_LETTER[-1] anzusprechen. Wir könnten das Problem umgehen, wenn wir category mit 1 statt mit 0 initialisieren, aber das würde bedeuten, dass einer negativen Note ein „F” zugeordnet werden würde, obwohl dieser eigentlich überhaupt kein String zugeordnet werden sollte, weil negative Werte keiner Kategorie angehören.

Stattdessen könnten wir _grade vor der Konvertierung in eine Schulnote überprüfen. In der setGrade-Methode tun wir dies bereits und sollten daher diesen Code in eine dritte Methode „auslagern”, statt der letterGrade-Methode weiteren Code zur Überprüfung hinzuzufügen. (Vielleicht fragen Sie sich jetzt, wie es überhaupt zu einer ungültigen Note kommen kann, wenn diese doch bei der Zuweisung überprüft werden. Wir könnten uns jedoch dafür entscheiden, im Konstruktor eine ungültige Note zuzuweisen, um zu signalisieren, dass noch keine gültige Note zugewiesen wurde.) Dies ist eine weitere Art von unterstützenden Methoden und bei Klassen das Pendant zum Konzept allgemeiner Hilfsfunktionen, das in den vorhergehenden Kapiteln vorgestellt wurde. Nun implementieren wir diese Methode und sorgen dafür, dass unsere Methode die neue verwendet:

bool studentRecord::isValidGrade(int grade) {
    if ((grade >= 0) && (grade <= 100))
        return true;
    else
        return false;
}
void studentRecord::setGrade(int newGrade) {
    if (isValidGrade(newGrade))
        _grade = newGrade;
}
string studentRecord::letterGrade() {
    if (!isValidGrade(_grade)) return "ERROR";
    const int NUMBER_CATEGORIES = 11;
    const string GRADE_LETTER[] = {"F", "D", "D+", "C-", "C",
                           "C+", "B-", "B", "B+", "A-", "A"};
    const int LOWEST_GRADE_SCORE[] = {0, 60, 67, 70, 73,
                                77, 80, 83, 87, 90, 93};
    int category = 0;
    while (category < NUMBER_CATEGORIES &&
           LOWEST_GRADE_SCORE[category] <= _grade)
            category++;
    return GRADE_LETTER[category - 1];
}

Diese neue Methode zur Überprüfung ist vom Typ bool , und da die Antwort entweder true oder false lautet, habe ich die Bezeichnung isValidGrade gewählt. Das kommt beim Aufruf der Methode, wie auch im Fall der Methoden setGrade und letterGrade , der natürlichen Sprache am nächsten. Beachten Sie, dass die Methode die zu überprüfende Note als Parameter entgegennimmt . letterGrade überprüft den Wert schon per Membervariablen _grade, und setGrade prüft ihn, falls wir uns dazu entscheiden, der Membervariable selbst einen Wert zuzuweisen. isValidGrade muss daher die Note als Parameter entgegennehmen, um für die anderen beiden Methoden von Nutzen zu sein.

Obwohl isValidGrade bereits implementiert ist, bleibt noch eine Frage offen: Welche Zugriffsrechte sollen wir für die Methode vergeben? Sollte sie im öffentlichen Abschnitt der Klasse oder im privaten stehen? Im Gegensatz zu den get- und set-Methoden unseres Grundgerüsts, die sich immer im öffentlichen Abschnitt befinden, können unterstützende Methoden sowohl öffentlich als auch privat sein, je nach Art der Verwendung. Welche Auswirkungen hat es, wenn isValidGrade öffentlich ist? Dann kann offenkundig der Client-Code darauf zugreifen. Viele angehende Programmierer tendieren dazu, sämtliche Methoden, die der Client möglicherweise nutzen könnte, öffentlich zu machen, weil ihnen eine Klasse mit zusätzlichen Methoden nützlicher erscheint. Dabei wird jedoch der zweite Effekt übersehen, den der öffentliche Zugriff mit sich bringt. Der öffentliche Abschnitt legt auch die Schnittstelle zu unserer Klasse fest, und wir sollten beim Ändern von Methoden zurückhaltend sein, sobald unsere Klasse in anderen Programmen integriert ist, weil solche Änderungen oft weitere Änderungen nach sich ziehen und dann der gesamte Client-Code überarbeitet werden muss. Durch die Platzierung einer Methode im öffentlichen Abschnitt wird daher deren Schnittstelle gewissermaßen „eingefroren” und es wird beschränkt, welche Auswirkungen die Methode hat. Stellen Sie sich im vorliegenden Fall beispielsweise vor, dass der Client-Code sich aufgrund der ursprünglichen isValidGrade-Methode auf die Überprüfung des Wertebereichs von 0 bis 100 verlässt, doch dann werden die Regeln für gültige Noten komplizierter. Der Client-Code könnte nun versagen. Um so etwas zu vermeiden, könnte es notwendig sein, stattdessen eine zweite Methode zur Überprüfung der Notenwerte für diese Klasse zu schreiben und die ursprüngliche Version unangetastet zu lassen.

Wir entschieden also, dass isValidGrade dem Client keinen nennenswerten Nutzen bringt, und machen die Methode nicht öffentlich. Wir könnten sie also als private deklarieren, aber das ist nicht unsere einzige Wahl. Da die Funktion keine direkten Verweise auf Membervariablen oder andere Methoden der Klasse enthält, wäre es auch möglich, sie außerhalb der Klasse zu deklarieren. Allerdings bringt das nicht nur dieselben Probleme hinsichtlich der Änderungsmöglichkeiten beim öffentlichen Zugriff mit sich, sondern vermindert auch das Ausmaß der Verkapselung, weil diese Funktion, die von der Klasse benötigt wird, dann nicht mehr Teil dieser Klasse ist. Wir könnten die Methode auch in der Klasse belassen und sie nicht als private, sondern als protected (geschützt) deklarieren. Ein Unterschied würde sich in allen abgeleiteten Klassen bemerkbar machen. Wenn die Methode geschützt ist, darf isValidGrade von Methoden in abgeleiteten Klassen aufgerufen werden. Ist sie hingegen privat, dürfen das nur andere Methoden der Klasse studentRecord. Es ist dasselbe Dilemma wie im Fall public kontra private in kleinerem Maßstab. Ist zu erwarten, dass Methoden in abgeleiteten Klassen regen Gebrauch von der Methode machen? Und ist zu erwarten, dass die Arbeitsweise oder die Schnittstelle der Methode künftig geändert wird? Manchmal ist es das Sicherste, alle Hilfsmethoden privat zu deklarieren und nur solche unterstützenden Methoden öffentlich zu machen, die ausdrücklich zum Wohle des Clients programmiert wurden.

5.4 Klassen mit dynamischen Daten

Die Verkapselung dynamischer Datenstrukturen ist einer der besten Gründe für das Erstellen einer Klasse. In Kapitel 4 wurde deutlich, dass die Nachverfolgung dynamischer Speicherreservierungen, Zuweisungen zu Zeigern und Freigaben von reservierten Speicherbereichen für Programmierer lästige Pflichten darstellen, die jedoch unverzichtbar sind, um Speicherlecks, verwaiste Referenzen und Verweise auf ungültige Speicherbereiche zu verhindern. Wenn man alle Vorkommen von Zeigern in eine Klasse steckt, macht das die Sache zwar nicht einfacher, erlaubt uns aber, korrekt funktionierenden Code gefahrlos in anderen Projekten zu übernehmen. Darüber hinaus sind sämtliche Probleme der dynamischen Datenstruktur auf den Code der Klasse beschränkt, was das Debuggen vereinfacht.

Wir erstellen nun eine Klasse mit dynamischen Daten, um die Arbeitsweise zu untersuchen. Als Beispielaufgabe verwenden wir eine veränderte Version der Hauptaufgabenstellung aus Kapitel 4.

Aufgabe: Nachverfolgen einer unbekannten Zahl von Schülerdatensätzen

Zur Lösung dieser Aufgabenstellung werden Sie Methoden zum Speichern und Bearbeiten von Schülerdatensätzen programmieren. Diese Datensätze bestehen aus zwei Ganzzahlen, nämlich der Schüler-ID und einer Note sowie einem String, der den Namen des Schülers enthält. Die folgenden Funktionen sind zu implementieren:

addRecord Diese Methode nimmt eine Schüler-ID, einen Namen und eine Note entgegen und fügt der Sammlung einen neuen Datensatz mit den übergebenen Daten hinzu.

recordWithNumber Diese Methode nimmt eine Schüler-ID entgegen und ruft den Datensatz mit dieser Schüler-ID aus der Sammlung ab.

removeRecord Diese Methode nimmt eine Schüler-ID entgegen und löscht den Datensatz mit dieser Schüler-ID aus der Sammlung.

Die Anzahl der Schülerdatensätze ist beliebig. Es ist davon auszugehen, dass die addRecord-Methode häufig aufgerufen wird. Sie muss daher möglichst effizient implementiert werden.

Die wesentlichen Unterschiede zwischen dieser Aufgabenstellung und der ursprünglichen Version sind die neue Operation recordWithNumber und dass bei keiner der Operationen von Zeigern als Parameter die Rede ist. Dies ist der entscheidende Vorteil, wenn man eine Klasse dazu verwendet, eine verkettete Liste zu verkapseln. Dem Client mag vielleicht bekannt sein, dass die Klasse die Schülerdatensätze als verkettete Liste implementiert und sich möglicherweise sogar darauf verlassen (siehe hierzu die vorangegangene Diskussion über die Beschränkungen des Information Hidings), allerdings gibt es keine direkten Interaktionen zwischen dem Client-Code und der verketteten Liste oder irgendeinem Zeiger in der Klasse.

Da bei dieser Aufgabenstellung die gleichen Schülerinformationen gespeichert werden wie in der ursprünglichen Aufgabe, bietet sich hier die Gelegenheit zur Wiederverwendung einer Klasse. In den Knoten unserer verketteten Liste verwenden wir statt der separaten drei Felder für die Informationen über jeden Schüler eine studentRecord-Instanz. Die Verwendung einer Klasse als Datentyp in einer anderen Klasse wird als Aggregation oder Komposition bezeichnet (je nachdem, ob der Datentyp auch ohne die ihn umschließende Klasse fortbesteht oder nicht).

Wir besitzen nun genügend Informationen, um eine (vorläufige) Deklaration der Klasse zu erstellen:

class studentCollection {
private:
    struct studentNode {
        studentRecord studentData;
        studentNode * next;
    };
public:
    studentCollection();
    void addRecord(studentRecord newStudent);
    studentRecord recordWithNumber(int idNum);
    void removeRecord(int idNum);
private:
    typedef studentNode * studentList;
    studentList _listHead;
};

Ich habe vorhin erwähnt, dass Programmierer dazu neigen, die öffentlichen Deklarationen an den Anfang der Klasse zu stellen, aber in diesem Fall müssen wir eine Ausnahme machen. Zunächst erfolgt die private-Deklaration der struct für den Knoten studentNode , den wir für den Aufbau der verketteten Liste verwenden werden. Diese Deklaration muss vor dem öffentlichen Abschnitt stehen, da einige der öffentlichen Memberfunktionen diesen Datentyp verwenden. Im Gegensatz zu dem in Kapitel 4 verwendeten Knoten besitzt dieser keine einzelnen Felder für die Datennutzlast, sondern verwendet eine Membervariable der studentRecord-Klasse . Die öffentlichen Memberfunktionen leiten sich direkt aus der Aufgabenstellung ab. Darüber hinaus gibt es, wie immer, einen Konstruktor. Im zweiten privaten Abschnitt deklarieren wir, wie in Kapitel 4, der Deutlichkeit halber eine typedef für einen Zeiger auf den Typ unseres Knotens. Schließlich deklarieren wir noch den Startzeiger unserer Liste namens _listHead .

In dieser Klasse werden zwei private Datentypen deklariert, denn Klassen können neben Memberfunktionen und Membervariablen auch Datentypen deklarieren. Dabei dürfen, wie bei den anderen Elementen, alle Schlüsselwörter zur Festlegung der Zugriffsrechte verwendet werden. Sie sollten diese Typdefinitionen, ebenso wie Membervariablen, standardmäßig als private vornehmen und den Zugriff nur dann weniger stark beschränken, wenn es dafür gute Gründe gibt. Die Arbeitsweise einer Klasse „hinter den Kulissen” hängt typischerweise in hohem Maße von diesen Typdeklarationen ab und diese sind daher für das Information Hiding von großer Bedeutung. Darüber hinaus kann der Client-Code in den meisten Fällen ohnehin nichts mit den Datentypen anfangen, die Sie in Ihrer Klasse deklariert haben. Es gibt den Ausnahmefall, dass ein in der Klasse deklarierter Typ Rückgabewert oder Parameter einer öffentlichen Methode ist. Dieser Typ muss dann öffentlich sein, sonst kann der Client-Code die öffentliche Methode nicht verwenden. In der Klasse studentCollection wird davon ausgegangen, dass der Datentyp studentRecord separat deklariert wird, wir könnten diesen jedoch auch zum Bestandteil der Klasse machen; wir müssten ihn dann im öffentlichen Abschnitt deklarieren.

Nun können wir, angefangen beim Konstruktor, unsere Klassenmethoden implementieren. Im Gegensatz zum vorigen Beispiel gibt es hier nur den Standardkonstruktor und keinen, der einen Parameter zur Initialisierung der Membervariablen erfordert. Der eigentliche Sinn der Klasse ist es, die Details der verketteten Liste zu verbergen. Der Client soll sich keine Gedanken über den Startzeiger _listHead machen, geschweige denn diesen ändern. Wir müssen in unserem Standardkonstruktor lediglich dem Startzeiger den Wert NULL zuweisen:

studentCollection::studentCollection() {
    _listHead = NULL;
}

5.4.1 Hinzufügen eines Knotens

Es geht weiter mit addRecord. Da in der Aufgabenstellung keine Rede davon ist, dass die Schülerdatensätze in einer bestimmten Reihenfolge bleiben müssen, können wir die addRecord-Funktion aus Kapitel 4 unmittelbar für den jetzigen Gebrauch anpassen.

void studentCollection::addRecord(studentRecord newStudent) {
      studentNode * newNode = new studentNode;
      newNode->studentData = newStudent;
      newNode->next = _listHead;
      _listHead = newNode;
}

Es gibt nur zwei Unterschiede zwischen der Vorlage und diesem Code. Wir benötigen nur einen Parameter , nämlich die studentRecord-Instanz, die wir den Datensätzen hinzufügen möchten. Sämtliche Daten eines Schülers sind darin verkapselt, wodurch die Anzahl der erforderlichen Parameter sinkt. Wir müssen auch keinen Startzeiger übergeben, denn der ist in unserer Klasse bereits als _listHead gespeichert und wird bei Bedarf direkt verwendet. Wie in der addRecord-Funktion aus Kapitel 4 erstellen wir einen neuen Knoten , kopieren die neuen Schülerdaten in den neuen Knoten , weisen dem next-Feld des Knotens den Wert des ehemals ersten Knotens der Liste zu und sorgen schließlich dafür, dass _listHead auf den neuen Knoten zeigt . Normalerweise empfehle ich bei allen Zeigermanipulationen das Zeichnen eines Diagramms, aber da es sich hier um eine Operation handelt, die wir bereits durchgeführt haben, können wir das früher erstellte Diagramm heranziehen.

Nun können wir unsere Aufmerksamkeit der Memberfunktion recordWithNumber widmen. Der Name ist etwas sperrig und manche Programmierer hätten eine kürzere Bezeichnung gewählt. Ich habe mich jedoch entsprechend der bereits erwähnten Namenskonvention dazu entschlossen, ein Substantiv zu verwenden, weil die Methode einen Wert zurückliefert. Diese Methode besitzt eine gewisse Ähnlichkeit mit averageRecord, da hier ebenfalls die Liste durchlaufen wird. In diesem Fall besteht der Unterschied darin, dass wir das Durchlaufen abbrechen können, sobald der passende Schülerdatensatz gefunden ist.

studentRecord studentCollection::recordWithNumber(int idNum) {
    studentNode * loopPtr = _listHead;
    while (loopPtr->studentData.studentID() != idNum) {
        loopPtr = loopPtr->next;
    }
    return loopPtr->studentData;
}

Wir initialisieren den Zeiger der Schleife mit dem Startzeiger und durchlaufen die verkettete Liste, bis die gesuchte Schüler-ID gefunden ist . Nachdem wir beim Zielknoten angekommen sind, geben wir den kompletten Schülerdatensatz als Funktionswert zurück . Das sieht schon ganz gut aus, aber wie üblich müssen wir die möglichen Spezialfälle betrachten. Beim Umgang mit verketteten Listen müssen wir immer testen, was passiert, wenn der Startzeiger den Wert NULL besitzt. Hier gibt es eindeutig ein Problem, da wir diesen Fall nicht überprüfen und der Code sich aufhängt, wenn wir beim ersten Eintritt in die Schleife versuchen, loopPtr zu dereferenzieren. Wir müssen im Allgemeinen außerdem die Möglichkeit berücksichtigen, dass die vom Client übergebene Schüler-ID mit keinem der Datensätze übereinstimmt. In diesem Fall wird loopPtr der Wert NULL zugewiesen, wenn wir am Ende der Liste ankommen, auch wenn _listHead von NULL verschieden ist.

Wir müssen also die Schleife abbrechen, sobald loopPtr den Wert NULL besitzt. Das ist nicht weiter schwer, aber was geben wir dann als Funktionswert zurück? Jedenfalls scheidet loopPtr->studentData aus, denn loopPtr ist ja NULL. Stattdessen wird ein studentRecord-Platzhalter mit offensichtlich ungültigen Werten erstellt und zurückgegeben.

studentRecord studentCollection::recordWithNumber(int idNum) {
    studentNode * loopPtr = _listHead;
    while (loopPtr != NULL &&
           loopPtr->studentData.studentID() != idNum) {
        loopPtr = loopPtr->next;
    }
    if (loopPtr == NULL) {
        studentRecord dummyRecord(-1, -1, "");
        return dummyRecord;
    } else {
        return loopPtr->studentData;
    }
}

In dieser Version der Methode erstellen wir einen Platzhalterdatensatz mit einem Leerstring als Namen und negativen Werten für Note und Schüler-ID und geben diesen zurück, wenn der Zeiger am Ende der Schleife den Wert NULL besitzt . Innerhalb der Schleife prüfen wir, ob loopPtr den Wert NULL besitzt, was bei einer leeren Liste oder nach erfolglosem Durchlaufen der Liste der Fall sein kann. Ein entscheidender Punkt ist hier die Abbruchbedingung der Schleife, die aus einem zusammengesetztem Ausdruck besteht, der mit loopPtr != NULL beginnt. Dies ist unbedingt erforderlich. C++ verwendet bei mehrteiligen booleschen Ausdrücken eine Kurzschlussauswertung (englisch short circuit evaluation). Kurz ausgedrückt wird dabei der verbleibende rechte Teil eines zusammengesetzten booleschen Ausdrucks überhaupt nicht mehr ausgewertet, wenn das Ergebnis des gesamten Ausdrucks bereits feststeht. Da der Operator && ein boolesches UND repräsentiert, muss der gesamte Ausdruck falsch sein, wenn der linke Teil falsch ist, egal was die Auswertung des rechten Teils ergibt. Aus Effizienzgründen nutzt C++ diese Tatsache und überspringt in solch einem Fall die Auswertung des rechten Teils, wenn der linke Teil sich als falsch herausstellt (aus dem gleichen Grund wird die rechte Seite eines ||-Ausdrucks, der ein logisches ODER enthält, übersprungen, wenn die linke Seite des Ausdrucks wahr ist). Wenn loopPrt also NULL und der Ausdruck loopPtr != 0 somit falsch ist, wird die rechte Seite nicht ausgewertet. Ohne Kurzschlussauswertung würde die rechte Seite ausgewertet werden, es käme zur Dereferenzierung eines Nullzeigers, und das Programm würde abstürzen.

Diese Implementierung ist vom potenziellen Absturz der ursprünglichen Version nicht betroffen, aber wir sollten uns dennoch bewusst sein, dass hier ein ziemlich großer Teil der Verantwortung beim Client-Code liegt. Die aufrufende Funktion ist nämlich dafür zuständig, den zurückgelieferten studentRecord zu überprüfen und vor der Weiterverarbeitung sicherzustellen, dass es sich nicht um den Platzhalterdatensatz handelt. Wenn es Ihnen so geht wie mir, beunruhigt Sie das ein wenig.

Tipp

Exceptions

Es gibt eine weitere Alternative. Wie viele andere Programmiersprachen auch bietet C++ einen als Exception bezeichneten Mechanismus, der es einer Funktion oder Methode gestattet, dem aufrufenden Code auf unmissverständliche Weise zu signalisieren, dass ein Fehler aufgetreten ist. Dieser Mechanismus ist für Situationen wie diese gedacht, nämlich wenn es auf die Frage, was die Methode bei fehlerhaften Eingabewerten zurückliefern soll, keine befriedigende Antwort gibt. Eine ausführliche Erläuterung von Exceptions würde an dieser Stelle jedoch zu weit führen, und leider ist die Art und Weise der Implementierung von Exceptions in C++ nicht dazu geeignet, das im vorigen Absatz angesprochene Problem der übertragenen Verantwortung zu lösen.

5.4.2 Umorganisieren einer Liste

Die removeRecord-Methode ähnelt recordWithNumber, denn die Liste muss durchlaufen werden, um den Knoten zu finden, der gelöscht werden soll, allerdings kommt hier noch einiges mehr hinzu. Beim Löschen eines Knotens aus der Liste muss darauf geachtet werden, dass die in der Liste verbleibenden Knoten miteinander verbunden bleiben. Das einfachste Verfahren, die entstandene Lücke zu schließen, besteht darin, die Knoten vor und hinter dem gelöschten Knoten miteinander zu verknüpfen. Ein Funktionsskelett brauchen wir hier nicht, denn es gibt bereits einen Prototyp in der Klassendeklaration. Wir benötigen nur ein Fallbeispiel:

studentCollection s;
studentRecord stu3(84, 1152, "Sue");
studentRecord stu2(75, 4875, "Ed");
studentRecord stu1(98, 2938, "Todd");
s.addRecord(stu3);
s.addRecord(stu2);
s.addRecord(stu1);
s.removeRecord(4875);

Wir haben hier eine studentCollection-Instanz sowie drei studentRecord-Instanzen erstellt und unserer Datensammlung hinzugefügt. Beachten Sie, dass wir auch einen einzelnen Datensatz hätten wiederverwenden können, indem wir dessen Werte zwischen den Aufrufen von addRecord ändern, aber wenn wir es auf die obige Weise erledigen, vereinfacht das unseren Testcode. Dessen letzte Zeile ist der Aufruf von removeRecord , wodurch in diesem Fall der zweite Datensatz gelöscht wird, derjenige für den Schüler „Ed”. Wir verwenden hier dieselbe Art von Zeigerdiagrammen wie in Kapitel 4. Abbildung 5.1 zeigt den Zustand des Arbeitsspeichers vor und nach diesem Aufruf.

Abbildung 5.1 (a) zeigt die von unserem Testcode erzeugte verkettete Liste. Beachten Sie, dass sich die Konventionen bezüglich des Diagramms ein wenig geändert haben, da wir nun eine Klasse verwenden. Auf der linken Seite der Stack/Heap-Trennlinie befinden sich _listHead, die private Membervariable der studentCollection-Instanz s, und idNum, der revomeRecord-Parameter. Rechts der Linie befindet sich die im Heap gespeicherte Liste selbst. addRecord platziert neue Datensätze am Anfang der Liste, daher sind die Datensätze in der umgekehrten Reihenfolge angeordnet, in der sie durch den Testcode hinzugefügt wurden. Der mittlere Knoten „Ed” besitzt die Schüler-ID 4875, was dem Parameter entspricht, und wird daher gelöschtAbbildung 5.1. (b) zeigt das Resultat des Aufrufs. Der erste Knoten der Liste („Todd”) zeigt nun auf den ehemals dritten Knoten („Sue”). Der Knoten „Ed” ist nicht mehr mit der Liste verknüpft und wurde gelöscht.

Abb. 5.1: Der Zustand vor und nach dem Aufruf von removeRecord

Wir wissen nun, was der Code bewirken soll, und können anfangen, ihn zu schreiben. Es ist klar, dass wir den Knoten mit der passenden Schüler-ID finden müssen, und wir könnten daher die while-Schleife von recordWithNumber verwenden. Nach dem Ende dieser Schleife verfügen wir über einen Zeiger auf den Knoten, der gelöscht werden soll. Um den Löschvorgang abzuschließen, müssen wir jedoch noch einige weitere Schritte erledigen. Werfen Sie ein Blick auf Abbildung 5.1. Um die Lücke zu schließen und die Verknüpfung der Liste zu erhalten, müssen wir das next-Feld des Knotens „Todd” ändern. Wenn wir aber nur über einen Zeiger auf den Knoten „Ed” verfügen, gibt es keine Möglichkeit, den Knoten „Todd” zu referenzieren, da jeder Knoten in der Liste auf seinen Nachfolger verweist, nicht jedoch auf seinen Vorgänger. (Aus diesem Grund gibt es verkettete Listen, die Verknüpfungen in beide Richtungen besitzen, sogenannte doppelt oder mehrfach verkettete Listen, diese werden jedoch nur selten benötigt.) Wir brauchen daher nicht nur einen Zeiger auf den zu löschenden Knoten (der loopPtr heißt, wenn wir den Code der vorigen Funktion übernehmen), sondern auch einen Zeiger auf den vorhergehenden, dem wir die Bezeichnung trailing geben. Abbildung 5.2 zeigt den Zustand des Arbeitsspeichers in unserem Fallbeispiel.

Abb. 5.2: Die zum Löschen des Knotens idNum erforderlichen Zeiger

Wenn also loopPtr auf den zu löschenden Knoten und trailing auf dessen Vorgänger verweist, können wir dafür sorgen, dass die Liste intakt bleibt, wenn wir den Knoten entfernen.

void studentCollection::removeRecord(int idNum) {
    studentNode * loopPtr = _listHead;
    studentNode * trailing = NULL;
    while (loopPtr != NULL &&
           loopPtr->studentData.studentID() != idNum) {
         trailing = loopPtr;
         loopPtr = loopPtr->next;
    }
    if (loopPtr == NULL) return;
    trailing->next = loopPtr->next;
    delete loopPtr;
}

Der erste Abschnitt dieser Funktion entspricht demjenigen der recordWithNumber-Funktion, mit dem Unterschied, dass wir den Zeiger trailing deklarieren und diesem innerhalb der Schleife den alten Wert von loopPtr zuweisen , bevor wir dafür sorgen, dass loopPtr auf den nächsten Knoten zeigt. Dadurch befindet sich trailing immer eine Position hinter loopPtr. Dank der Erfahrung mit der Vorgängerversion sind wir auf der Hut vor Spezialfällen und überprüfen nach dem Ende der Schleife, ob loopPtr den Wert NULL besitzt. In diesem Fall wurde der Knoten mit der gesuchten Schüler-ID nicht gefunden und die Funktion bricht mit return ab . Solch eine return-Anweisung inmitten einer Funktion kommt einer „Flucht” gleich, und manche Programmierer beschweren sich darüber, weil Funktionen, die an mehreren Stellen beendet werden können, manchmal schwer verständlich sind. In diesem Fall wäre die Alternative allerdings eine weitere Verschachtelungsebene in der if-Anweisung, und dann ergreife ich doch lieber die Flucht.

Wird der fragliche Knoten hingegen gefunden, ist es nun an der Zeit, diesen zu löschen. Aus dem Diagramm ist ersichtlich, dass wir dem next-Feld des Knotens trailing den Knoten zuweisen müssen, auf den das next-Field von loopPtr zeigt . Danach können wir gefahrlos den Knoten löschen, auf den loopPtr zeigt .

In unserem Fallbeispiel funktioniert alles einwandfrei, daher testen wir nun, wie üblich, die Spezialfälle. Die Möglichkeit, dass idNum in der Datensammlung nicht vorhanden ist, haben wir bereits berücksichtigt. Gibt es weitere Sonderfälle? Würde sich etwas ändern, wenn wir in unserem Fallbeispiel nicht den mittleren, sondern den ersten oder letzten Knoten löschen? Der letzte Knoten bereitet keinerlei Probleme, doch der erste macht Schwierigkeiten, weil es hier keinen Vorgängerknoten gibt, auf den trailing verweisen könnte. Stattdessen müssen wir den Startzeiger der Liste selbst, _listHead, ändern. Abbildung 5.3 zeigt den Zustand nach dem Beenden der while-Schleife.

Abb. 5.3: Der Zustand vor dem Löschen des ersten Knotens

Wir müssen hier den Zeiger _listHead auf den ehemals zweiten Knoten der Liste („Ed”) „umbiegen”. Schreiben wir also unsere Methode um, damit der Spezialfall berücksichtigt wird.

void studentCollection::removeRecord(int idNum) {
    studentNode * loopPtr = _listHead;
    studentNode * trailing = NULL;
    while (loopPtr != NULL &&
           loopPtr->studentData.studentID() != idNum) {
        trailing = loopPtr;
        loopPtr = loopPtr->next;
    }
    if (loopPtr == NULL) return;
    if (trailing == NULL) {
        _listHead = _listHead->next;
    } else {
        trailing->next = loopPtr->next;
    }
    delete loopPtr;
}

Wie Sie sehen, sind weder die Abfragebedingung noch der Code zur Einbeziehung des Spezialfalls sonderlich kompliziert, da wir die Situation vorher sorgfältig analysiert haben.

5.4.3 Destruktor

Man könnte nun glauben, dass die studentCollection-Klasse mit der Implementierung der drei Methoden aus der Aufgabenstellung vollständig ist. Stand der Dinge ist jedoch, dass es noch ernsthafte Probleme gibt. Der Klasse fehlt noch ein Destruktor. Diese spezielle Methode wird aufgerufen, bevor eine Instanz aufgelöst wird (wenn die Funktion endet, in der die Instanz deklariert wurde). Eine Klasse ohne dynamische Daten benötigt typischerweise keinen Destruktor. Gibt es hingegen dynamische Daten, ist ein Destruktor unverzichtbar. Denken Sie daran, dass alle mit new reservierten Speicherbereiche mit delete wieder freigegeben werden müssen, um Speicherlecks zu verhindern. Wenn eine Instanz der studentCollection-Klasse drei Knoten besitzt, muss der Speicherbereich jeder dieser Knoten wieder freigegeben werden. Glücklicherweise ist dies nicht weiter schwierig. Wir müssen lediglich die Liste durchlaufen und dabei die einzelnen Knoten mit delete freigeben. Anstatt dies jedoch direkt zu erledigen, schreiben wir eine Hilfsmethode, die sämtliche Knoten in einer studentList freigibt. Im privaten Abschnitt unserer Klasse fügen wir die Deklaration hinzu:

void deleteList(studentList &listPtr);

Der Code für die Methode selbst sieht wie folgt aus:

void studentCollection::deleteList(studentList &listPtr) {
    while (listPtr != NULL) {
         studentNode * temp = listPtr;
         listPtr = listPtr->next;
         delete temp;
    }
}

Beim Durchlauf wird der Zeiger auf den aktuellen Knoten in eine temporäre Variable kopiert . Danach wird der nächste Knoten aufgesucht und der Knoten, auf den die temporäre Variable verweist, mit delete gelöscht . Nachdem diese Methode erstellt ist, wird die Programmierung des Destruktors zum Kinderspiel. Zunächst fügen wir den Destruktor dem öffentlichen Abschnitt unserer Klassendeklaration hinzu:

˜studentCollection();

Beachten Sie, dass der Destruktor, ebenso wie ein Konstruktor, den Namen der Klasse erhält und dass es keinen Rückgabewert gibt. Der Destruktor unterscheidet sich durch die Tilde am Anfang des Namens von den Konstruktoren. Die Implementierung geschieht folgendermaßen:

studentCollection::˜studentCollection() {
    deleteList(_listHead);
}

Der Code der beiden Methoden ist unkompliziert, es ist aber wichtig, den Destruktor zu testen. Zwar kann ein mangelhaft geschriebener Destruktor ein Programm durchaus zum Absturz bringen, viele Destruktorprobleme führen jedoch nicht zu Abstürzen, sondern zu Speicherlecks, oder noch schlimmer, zu unerklärlichem Verhalten des Programms. Daher ist es von großer Bedeutung, den Destruktor mit dem Debugger Ihrer Entwicklungsumgebung zu überprüfen, sodass Sie dabei zusehen können, wie der Destruktor tatsächlich für jeden Knoten delete aufruft.

5.4.4 Tiefe Kopien (Deep Copy)

Es gibt noch ein weiteres ernsthaftes Problem. In Kapitel 4 kam kurz das Konzept der Quervernetzung vor, bei der zwei Zeiger denselben Wert besitzen. Auch wenn die Variablen selbst sich unterscheiden, zeigen sie doch auf dieselbe Datenstruktur, und das Ändern einer der Variablen modifiziert tatsächlich beide. Bei Klassen, die dynamisch reservierte Speicherbereiche verwenden, kann dieses Problem schnell auftreten. Warum dies so problematisch sein kann, wird durch folgenden C++-Codeabschnitt deutlich:

int x = 10;
int y = 15;
x = y;
x = 5;

Wenn ich Sie nun fragen würde, welche Auswirkung die letzte Anweisung auf den Wert der Variablen y hat, würden Sie vermutlich denken, ich hätte mich versprochen. Die Anweisung hat keinerlei Auswirkung auf y, nur auf x. Betrachten Sie nun den folgenden Code:

studentCollection s1;
studentCollection s2;
studentRecord r1(85, 99837, "John");
s2.addRecord(r1);
studentRecord r2(77, 4765, "Elsie");
s2.addRecord(r2);
s1 = s2;
s2.removeRecord(99837);

Und wenn ich Sie nun frage, welche Auswirkung die Anweisung auf s1 hat? Unseligerweise hat sie tatsächlich Auswirkung auf s1. s1 und s2 sind zwar verschiedene Instanzen, sie sind aber nicht mehr völlig voneinander unabhängige Objekte. Wenn ein Objekt einem anderen zugewiesen wird, wie hier s2 an s1 , erstellt C++ eine sogenannte flache Kopie (englisch shallow copy). Dabei werden die Membervariablen eines Objekts direkt den Membervariablen des anderen zugewiesen. Die Zuweisungen s1 = s2 und s1._listHead = s2._listHead wären gleichbedeutend, falls _listHead, die einzige Membervariable unserer Klasse, öffentlich wäre. Nun zeigen die _listHead-Membervariablen beider Objekte auf denselben Speicherbereich, nämlich den Knoten für „Elsie”, der seinerseits auf den anderen Knoten für „John” zeigt. Wird nun der Knoten für „John” gelöscht, erscheint es so, als ob er aus beiden Listen gelöscht wird, weil es tatsächlich nur noch eine Liste gibt. Abbildung 5.4 gibt die Verhältnisse nach Ausführung des Codes wieder.

Abb. 5.4: Eine flache Kopie führt zu Quervernetzung. Das Löschen des Knotens „John” entfernt diesen aus beiden Listen.

So skurril das Ganze auch ist, es hätte noch viel schlimmer kommen können. Was wäre, wenn die letzte Zeile des Codes den ersten Knoten für „Elsie” gelöscht hätte? In diesem Fall würde _listHead in s2 auf den Knoten „John” zeigen und der Knoten „Elsie” würde gelöscht werden. _listHead in s1 würde allerdings noch immer auf den gelöschten Knoten „Elsie” zeigen und es gäbe eine gefährliche verwaiste Referenz (Abbildung 5.5).

Abb. 5.5: Das Löschen in s2 verursacht eine verwaiste Referenz in s1.

Gelöst werden diese Probleme durch eine sogenannte tiefe Kopie (englisch deep copy). Dabei werden nicht einfach nur die Zeiger auf die Datenstruktur kopiert, sondern Kopien von allem innerhalb der Datenstruktur erstellt. Um eine echte Kopie der Liste zu erstellen, müssen also sämtliche Knoten innerhalb der Liste kopiert werden. Wie zuvor legen wir wieder eine private Hilfsmethode an, die in diesem Fall eine studentList kopiert. Die Deklaration im privaten Abschnitt der Klasse sieht wie folgt aus:

studentList copiedList(const studentList original);

Ich habe als Bezeichnung erneut ein Substantiv gewählt, da die Methode einen Wert zurückgibt. Die Implementierung sieht wie folgt aus:

studentCollection::studentList
studentCollection::copiedList(const studentList original) {
    if (original == NULL) {
        return NULL;
    }
    studentList newList = new studentNode;
    newList->studentData = original->studentData;
    studentNode * oldLoopPtr = original->next;
    studentNode * newLoopPtr = newList;
    while (oldLoopPtr != NULL) {
        newLoopPtr->next = new studentNode;
        newLoopPtr = newLoopPtr->next;
        newLoopPtr->studentData = oldLoopPtr->studentData;
        oldLoopPtr = oldLoopPtr->next;
    }
    newLoopPtr->next = NULL;
    return newList;
}

In dieser Methode geschieht eine ganze Menge und das sehen wir uns nun schrittweise an. Ein Hinweis zur Syntax: In der Implementierung müssen wir bei der Angabe des Rückgabewertes den Namen der Klasse voranstellen , da der Compiler ansonsten nicht erkennt, welchen Datentyp wir meinen. (Innerhalb der Methode ist das nicht erforderlich, da dem Compiler jetzt bekannt ist, zu welcher Klasse die Methode gehört. Es ist ein wenig verwirrend!) Eingangs prüfen wir, ob die übergebene Liste leer ist. Wenn dem so ist, ergreifen wir wieder die Flucht . Sobald klar ist, dass die Liste nicht leer ist, kopieren wir noch außerhalb der Schleife die Daten des ersten Knotens , weil wir hier den Startzeiger der neuen Liste ändern müssen.

Wir erstellen dann zwei Zeiger zum Durchlaufen der beiden Listen. oldLoopPtr durchläuft die alte Liste und verweist jeweils auf den Knoten, der gerade kopiert werden soll. newLoopPtr durchläuft die neue, kopierte Liste und verweist stets auf den letzten neu angelegten Knoten, der sich vor der Position befindet, an dem der nächste Knoten hinzugefügt wird. Wie in der removeRecord-Funktion benötigen wir hier einen Zeiger auf einen angrenzenden Knoten. In der Schleife erzeugen wir einen neuen Knoten, sorgen dafür, dass newLoopPtr auf diesen verweist, kopieren die Daten vom alten Knoten zum neuen und weisen oldLoopPtr den nächsten Knoten zu. Nach der Schleife kennzeichnen wir das Ende der neuen Liste, indem wir dem next-Feld des letzten Knotens den Wert NULL zuweisen und geben den Zeiger auf die neue Liste zurück .

Auf welche Weise löst diese Hilfsmethode das eben gesehene Problem? Für sich genommen gar nicht, aber wir können nun den Zuweisungsoperator überladen. Operatorüberladung ist ein Leistungsmerkmal von C++, das es uns gestattet, zu ändern, wie die eingebauten Operatoren mit bestimmten Datentypen verfahren. Im vorliegenden Fall möchten wir den Zuweisungsoperator (=) überladen, sodass er unsere copiedList-Methode aufruft und eine tiefe Kopie erstellt, statt nur eine flache Kopie anzufertigen. Wir fügen dem öffentlichen Abschnitt unserer Klasse Folgendes hinzu:

studentCollection &operator=
     (const studentCollection &rhs);

Der Operator, der überladen werden soll, wird mittels des Schlüsselworts operator gefolgt vom Operator selbst angegeben . Die von mir gewählte Bezeichnung für den Parameter (rhs ) wird beim Überladen häufig verwendet, denn er steht für „Rechte-Hand-Seite” (englisch right-hand side) und hilft dem Programmierer, sich die Bedeutung zu merken. In der Zuweisung s2 = s1, die uns zu alledem geführt hat, ist das Objekt s1 die rechte Seite und Objekt s2 die linke. Auf die rechte Seite verweisen wir durch den Parameter und auf die linke Seite durch direkte Angabe der Memberfunktion, wie auf jede andere Methode der Klasse auch. Wir müssen also eine Liste erzeugen, auf die _listHead zeigt, die eine Kopie derjenigen Liste ist, auf die _listHead in rhs zeigt. Durch die Zuweisung s2 = s1 wird dann s2 zu einer echten Kopie von s1.

Der Typ des Parameters ist hier immer ein konstanter Bezeichner der fraglichen Klasse und der Rückgabetyp stets eine Referenz auf die Klasse . Warum der Parameter eine Referenz ist, werden Sie in Kürze erfahren. Möglicherweise fragen Sie sich, warum die Methode überhaupt etwas zurückliefert, wenn doch die Membervariable in der Methode selbst direkt geändert wird. Das liegt daran, dass in C++ verkettete Zuweisungen erlaubt sind, wie z. B. s3 = s2 = s1, in denen der Rückgabewert einer einzelnen Zuweisung zum Parameter der nächsten Zuweisung wird.

Hat man die Syntax verstanden, ist der eigentliche Code für den Zuweisungsoperator ziemlich gradlinig:

studentCollection& studentCollection::operator=
                 (const studentCollection &rhs) {
    if (this != &rhs) {
         deleteList(_listHead);
         _listHead = copiedList(rhs._listHead);
    }
    return *this;
}

Wir müssen zunächst alle Knoten der Liste auf der linken Seite löschen , um ein Speicherleck zu verhindern. (Zu diesem Zweck haben wir deleteList als Hilfsmethode erstellt, anstatt den Code direkt dem Destruktor hinzuzufügen.) Sind die Knoten gelöscht, kopieren wir unter Verwendung unserer zweiten Hilfsmethode die Liste der rechten Seite . Bevor diese beiden Schritte ausgeführt werden, überprüfen wir, ob das Objekt auf der rechten Seite vom Objekt auf der linken Seite verschieden ist (es sich also nicht um einen Ausdruck wie s1 = s1 handelt), indem die Zeiger miteinander verglichen werden . Sind diese identisch, gibt es auch nichts zu erledigen, aber hierbei handelt es sich nicht um eine Frage der Effizienz, denn wenn wir eine tiefe Kopie mit identischen Zeigern durchführen, werden beim Löschen der Knoten der linksseitigen Liste diejenigen der rechtsseitigen Liste ebenfalls gelöscht. Schließlich geben wir einen Zeiger auf das linksseitige Objekt zurück . Dies geschieht unabhängig davon, ob wir tatsächlich etwas kopiert haben oder nicht, denn wenn Anweisungen wie s2 = s1 = s1 auch grotesk sind, sollte dennoch alles reibungslos funktionieren, wenn jemand das ausprobiert.

Da wir bereits über eine Hilfsmethode zum Kopieren von Listen verfügen, sollten wir auch einen Kopierkonstruktor erstellen. Dieser Konstruktor nimmt eine Referenz auf ein Objekt derselben Klasse als Parameter entgegen. Der Kopierkonstruktor kann jederzeit ausdrücklich aufgerufen werden, wenn wir ein Duplikat einer vorhandenen studentCollection benötigen, er wird jedoch auch automatisch aufgerufen, sobald eine Instanz der Klasse einer Funktion als Wert (nicht als Referenz) übergeben wird. Sie sollten daher Instanzen als const-Referenzen übergeben, sofern die Funktion, der die Instanz übergeben wird, die Kopie nicht ändern muss, weil ihr Code ansonsten eine Menge unnötiger Arbeit verrichtet. Stellen Sie sich beispielsweise eine Datensammlung von 10.000 Schülerdatensätzen vor. Diese könnte als Referenz, als einzelner Zeiger, übergeben werden oder aber den Kopierkonstruktor aufrufen und 10.000 Speicherplatzreservierungen auslösen. Und beim Beenden der Funktion würde die lokale Kopie den Destruktor aufrufen und damit 10.000 Freigaben reservierter Speicherbereiche verursachen. Aus diesem Grund verwendet der Parameter auf der rechten Seite beim Überladen des Zuweisungsoperators eine const-Referenz.

Um einen Kopierkonstruktor hinzuzufügen, müssen wir zunächst dessen Deklaration im öffentlichen Abschnitt unserer Klasse vornehmen.

studentCollection(const studentCollection &original);

Er besitzt, wie alle Konstruktoren, keinen Rückgabetyp und wie beim Überladen des Zuweisungsoperators ist der Parameter eine const-Referenz auf unsere Klasse. Die Implementierung ist einfach, da wir auf unsere Hilfsmethode zurückgreifen können:

studentCollection::studentCollection(const studentCollection &original) {
    _listHead = copiedList(original._listHead);
}

Nun können wir Deklarationen wie diese vornehmen:

studentCollection s2(s1);

Diese Zeile deklariert s2 und kopiert die Knoten von s1 nach s2.

5.4.5 Klassen mit dynamischen Daten im Überblick

Wir haben wirklich eine Menge an der Klasse geändert, seitdem wir die in der Aufgabenstellung genannten Methoden implementiert haben. Nehmen wir uns daher einen Moment Zeit für eine Zusammenfassung. Unsere Klassendeklaration sieht nun wie folgt aus:

class studentCollection {
private:
    struct studentNode {
        studentRecord studentData;
        studentNode * next;
    };
public:
    studentCollection();
    ˜studentCollection();
    studentCollection(const studentCollection &original);
    studentCollection& operator=(const studentCollection &rhs);
    void addRecord(studentRecord newStudent);
    studentRecord recordWithNumber(int idNum);
    void removeRecord(int idNum);
private:
    typedef studentNode * studentList;
    studentList _listHead;
    void deleteList(studentList &listPtr);
    studentList copiedList(const studentList original);
};

Sie können hieraus lernen, dass zum Erstellen einer Klasse mit dynamischen Daten weitere Sprachbestandteile erforderlich sind. Zusätzlich zu den Fähigkeiten unseres Grundgerüsts (private Membervariablen, Standardkonstruktor, Methoden zum Datenaustausch mit der Instanz) müssen wir weitere Methoden zur dynamischen Verwaltung des Speichers hinzufügen. Wir sollten außerdem mindestens einen Kopierkonstruktor und einen Destruktor erstellen sowie den Zuweisungsoperator überladen, falls auch nur die geringste Chance besteht, dass irgendjemand ihn verwendet. Das Erstellen dieser zusätzlichen Methoden wird durch Hilfsmethoden zum Kopieren oder Löschen der zugrunde liegenden dynamischen Datenstruktur oft erleichtert.

Das mag nach einem Haufen Arbeit aussehen, und das kann es auch durchaus sein, aber man darf hier nicht übersehen, dass alle zur Klasse hinzugefügten Funktionalitäten früher oder später ohnehin implementiert werden müssen. Mit anderen Worten: Auch wenn wir über keine Klasse zur Handhabung der verketteten Liste von Schülerdatensätzen verfügen, sind wir letzten Endes trotzdem dafür verantwortlich, die Knoten wieder zu löschen, wenn wir eine Liste nicht mehr benötigen. Wir müssten dennoch auf der Hut vor Quervernetzung sein und eine Liste Knoten für Knoten durchlaufen, um eine echte Kopie zu erstellen, usw. All dies in die Form einer Klassenstruktur zu bringen, bereitet nur wenig zusätzliche Arbeit, und sobald alles funktioniert, braucht sich der Client nicht um die Details der Speicherverwaltung zu kümmern. Letztlich machen Verkapselung und Information Hiding die Verwendung dynamischer Datenstrukturen erheblich einfacher.

5.5 Fehlervermeidung

Wie Sie in C++ eine vernünftige Klasse erstellen, haben Sie nun erfahren, und wir wollen die Erläuterungen abrunden, indem wir einige der gängigsten Fallstricke in Augenschein nehmen, die Sie tunlichst umgehen sollten.

5.5.1 Fingierte Klassen

Wie ich am Anfang des Kapitels erwähnt habe, halte ich C++ als Programmiersprache, die sowohl das prozedurale als auch das objektorientierte Modell unterstützt, für bestens geeignet, um damit objektorientierte Programmierung zu erlernen, weil das Erstellen einer Klasse stets vom Programmierer ausgehen muss. Programmiert man in einer Sprache wie Java, kommt man gar nicht auf den Gedanken, die Frage „Sollte ich hierfür eine Klasse verwenden?” zu stellen. Die Frage lautet vielmehr „Wie kann ich daraus eine Klasse machen?” Die Notwendigkeit, alles in Form einer Klasse zu verpacken, führt zu dem, was ich als fingierte Klassen bezeichne, Klassen ohne stringentes Design, die zwar syntaktisch korrekt sind, aber keine richtige Bedeutung besitzen. Das im Zusammenhang mit Programmierung verwendete Wort Klasse ist von der Bedeutung dieses Begriffs abgeleitet, die eine Gruppe von Objekten mit gemeinsamen Eigenschaften beschreibt. Diese Beschreibung trifft auch auf eine vernünftige C++-Klasse zu.

Fingierte Klassen kommen aus verschiedenen Gründen zustande. Eine Art dieser Klassen entsteht, wenn der Programmierer in Wahrheit globale Variablen verwenden will. Nicht etwa aus vertretbaren Gründen (die zwar existieren, aber äußerst selten sind), sondern aus Faulheit – nur um sich die Übergabe von Parametern von einer Funktion zur anderen zu ersparen. Dem Programmierer ist natürlich vollkommen klar, dass die Verwendung globaler Variablen auf breiter Front als fürchterlicher Stil gilt, aber er glaubt, ein Schlupfloch gefunden zu haben. Alle oder die meisten Funktionen des Programms werden in eine Klasse geschaufelt, und die Variablen, die anderenfalls global wären, sind nun Membervariablen der Klasse. Die main-Funktion des Programms erzeugt einfach nur eine Instanz der fingierten Klasse und ruft irgendeine Hauptmethode der Klasse auf. Rein technisch betrachtet verwendet das Programm nun zwar keine globalen Variablen, aber es weist genau dieselben Schwächen auf wie Programme, die ausgiebigen Gebrauch davon machen.

Eine andere Art fingierter Klassen tritt zutage, weil der Programmierer annimmt, dass objektorientierte Programmierung immer „besser” ist, und sie in Situationen erzwingt, in denen sie gar nicht angebracht ist. Der Programmierer erstellt dann oft eine Klasse, die sehr spezielle Funktionalitäten verkapselt, die nur innerhalb des Programms sinnvoll sind, für das die Klasse ursprünglich geschrieben wurde. Es gibt zwei Möglichkeiten, um zu überprüfen, ob Sie eine fingierte Klasse dieser Art schreiben. Fragen Sie sich zunächst „Kann ich dieser Klasse einen charakteristischen und einigermaßen kurzen Namen geben?” Wenn Sie feststellen, dass GehaltsabrechnungsBerichtsDurchführungsVerordnung ein angemessener Name wäre, haben Sie möglicherweise ein Problem. Die zweite Frage, die Sie sich stellen sollten, lautet: „Kann ich mir vorstellen, die Klasse mit geringfügigen Änderungen wiederzuverwenden, wenn ich ein Programm ähnlicher Funktionalität schreiben sollte, oder müsste ich erhebliche Teile umschreiben?”

Auch in C++ ist eine gewisse Zahl fingierter Klassen unvermeidbar, beispielsweise weil Daten verkapselt werden müssen, um sie in Containerklassen zu verwenden. Derartige Klassen sind aber für gewöhnlich klein und elementar. Wenn wir es vermeiden können, aufwendige fingierte Klassen zu verwenden, tut das der Qualität unseres Codes nur gut.

5.5.2 Monotalente

Wenn Sie jemals eine der unzähligen Kochsendungen im Fernsehen gesehen haben, sind Ihnen zweifelsohne auch schon Ratschläge erteilt worden, wie Sie Ihre Küche auszustatten haben, um möglichst effizient arbeiten zu können. Oft wird dabei auf Küchengeräte geschimpft, die ausschließlich einem einzigen Zweck dienen und für irgendwelche anderen Aufgaben völlig unbrauchbar sind. Mangels eines besseren Ausdrucks könnte man solche Gerätschaften (im Gegensatz zu Multitalenten) als „Monotalente” bezeichnen. Wir sollten beim Schreiben unserer Klassen danach streben, diese so allgemein wie möglich zu halten, gleichzeitig aber auch die für unser Programm erforderliche spezielle Funktionalität bereitzustellen.

Hierfür sind Templates gedacht. Dabei handelt es sich um ein Thema für Fortgeschrittene und die Syntax ist ein wenig undurchsichtig, aber Templates erlauben es, Klassen zu erstellen, bei denen der Datentyp einer (oder mehrerer) Membervariablen beim Erstellen einer Instanz festgelegt wird. Template-Klassen ermöglichen es, allgemeine Funktionalitäten „auszulagern”. Unsere studentCollection-Klasse enthält beispielsweise eine Menge Code, den jede Klasse verwendet, die eine verkettete Liste verkapselt. Wir könnten stattdessen eine Template-Klasse für eine ganz allgemeine verkettete Liste erstellen, die den Datentyp der Knoten der Liste erst dann festlegt, wenn eine Instanz der Template-Klasse erstellt wird, anstatt den Typ studentRecord „fest zu verdrahten”. Unsere studentCollection-Klasse besäße dann eine Membervariable vom Typ dieser Template-Klasse (statt einer Variablen für den Startzeiger) und würde die verkettete Liste nicht mehr direkt manipulieren.

Das Thema Template-Klassen geht über den Umfang dieses Buches weit hinaus, aber wenn Sie Ihre Fähigkeiten beim Entwerfen von Klassen allmählich weiterentwickeln, sollten Sie sich bemühen, Multitalente zu entwickeln, nicht Monotalente. Es ist wirklich ein erhebendes Gefühl, wenn man feststellt, dass eine Aufgabenstellung sich mit einer Klasse lösen lässt, bei deren Entwicklung Sie noch gar nicht wussten, dass das vorliegende Problem überhaupt existiert.

5.6 Übungen

Sie wissen doch sowieso schon, was jetzt kommt. Also nur zu! Probieren Sie einige der Übungen aus!

5.1

Erstellen Sie unter Verwendung unseres Grundgerüsts eine neue Klasse, die beispielsweise Informationen über Autos speichert: Hersteller und Modell, jeweils als String, sowie das Baujahr als Ganzzahl. Erstellen Sie get- und set-Methoden für die Membervariablen. Schenken Sie auch den Details Beachtung, wie etwa den Bezeichnungen der Membervariablen. Dabei müssen Sie natürlich nicht unbedingt meiner Namenskonvention folgen. Es ist nur wichtig, dass Sie sich Gedanken darüber machen und Ihre Konvention konsequent beachten.

5.2

Fügen Sie der Auto-Klasse aus der vorhergehenden Übung eine unterstützende Methode hinzu, die eine vollständige Beschreibung der Fahrzeuge als formatierte Zeichenkette zurückgibt, wie z. B. „Volkswagen Käfer 1966”. Fügen Sie eine zweite Methode hinzu, die das Alter des Autos in Jahren zurückliefert.

5.3

Verwenden Sie die Stringfunktionen aus Kapitel 4 (append, concatenate und characterAt), um eine Klasse für Zeichenketten variabler Länge zu erstellen. Implementieren Sie alle erforderlichen Konstruktoren, einen Destruktor und die Überladung des Zuweisungsoperators.

5.4

Ersetzen Sie in der Klasse aus der vorhergehenden Übung die Methode characterAt durch einen überladenen []-Operator. Falls also beispielsweise myString eine Instanz der Klasse ist, dann sollte myString[1] das gleiche Ergebnis wie myString.characterAt(1) liefern.

5.5

Fügen Sie der Klasse aus den vorhergehenden Übungen eine remove-Methode hinzu, die Startposition und Zeichenanzahl als Parameter entgegennimmt und die genannte Zahl von Zeichen, angefangen bei der Startposition, aus der Zeichenkette entfernt. Der Aufruf myString(5,3) würde also drei Zeichen ab der fünften Position entfernen. Achten Sie darauf, dass die Methode sich vernünftig verhält, wenn einer der Parameter einen ungültigen Wert besitzt.

5.6

Untersuchen Sie, ob für die Klasse aus den vorhergehenden Übungen ein Refactoring angebracht ist. Gibt es beispielsweise mehrfach vorhandene Funktionalitäten, die in eine private Hilfsmethode ausgelagert werden können?

5.7

Verwenden Sie die Funktionen für Schülerdatensätze aus Kapitel 4 (addRecord und averageRecord), um eine Klasse zu erstellen, die eine Sammlung von Schülerdatensätzen repräsentiert. Implementieren Sie, wie zuvor, alle erforderlichen Konstruktoren, einen Destruktor und die Überladung des Zuweisungsoperators.

5.8

Fügen Sie der Klasse aus der vorhergehenden Übung eine Methode RecordsWithinRange hinzu, die zwei Notenwerte als Parameter entgegennimmt und eine neue Sammlung von Datensätzen mit Notenwerten im genannten Wertebereich zurückliefert (die ursprüngliche Sammlung bleibt dabei unangetastet). Der Aufruf myCollection.RecordsWithinRange(75, 80) würde beispielsweise eine Sammlung aller Datensätze mit Punktzahlen im Bereich von 75 bis 80 (jeweils inklusive) zurückgeben.

Kapitel 6: Rekursion

In diesem Kapitel geht es um Rekursion, bei der sich eine Funktion direkt oder indirekt selbst aufruft. Auf den ersten Blick sieht rekursive Programmierung simpel aus und tatsächlich besitzt eine gelungene rekursive Lösung oft ein schlichtes, fast schon elegantes Erscheinungsbild. Allerdings ist der Weg zu solch einer Lösung häufig alles andere als einfach. Das liegt daran, dass Rekursion uns zwingt, auf andere Weise zu denken, als wir es bei anderen Arten der Programmierung tun. Wenn wir Daten unter Verwendung von Schleifen verarbeiten, stellen wir uns die Verarbeitung sequenziell vor. Bei rekursiver Verarbeitung hilft uns diese sequenzielle Denkweise nicht weiter. Viele gute angehende Programmierer quälen sich mit Rekursion herum, weil sie keinen Weg finden, ihre Problemlösungsfähigkeiten auf rekursive Aufgabenstellungen anzuwenden. In diesem Kapitel werden wir erläutern, wie sich rekursive Problemstellungen systematisch angehen lassen. Das Verfahren, das hierbei zur Anwendung kommt, möchte ich als das Hauptkonzept der Rekursion bezeichnen. Die zugrunde liegende Idee ist derart schlicht, dass es Ihnen wie Trickserei vorkommen wird, aber sie funktioniert.

6.1 Grundlagen der Rekursion

Über die Syntax hinsichtlich der Rekursion muss man nicht viel wissen. Die Schwierigkeiten ergeben sich, wenn man versucht, Rekursion zur Lösung von Aufgabenstellungen einzusetzen. Eine Rekursion tritt immer dann auf, wenn eine Funktion sich selbst aufruft, die Syntax der Rekursion ist daher mit der eines Funktionsaufrufs identisch. Die gebräuchlichste Form ist die direkte Rekursion, bei der ein Funktionsaufruf in der Funktion selbst erfolgt. Ein Beispiel:

int factorial(int n) {
    if (n == 1) return 1;
    else return n *factorial(n - 1);
}

Diese Funktion wird oft gebraucht, um Rekursion zu demonstrieren, ist jedoch äußerst ineffizient. Sie berechnet die Fakultät der Zahl n. Für den Fall n = 5 beispielsweise ist die Fakultät das Produkt der Zahlen von 5 bis 1, also 120. Beachten Sie, dass unter Umständen gar keine Rekursion auftritt. Ist der Wert des Parameters 1, tritt der sogenannte Basisfall der Rekursion ein und es wird ohne Rekursion ein Wert zurückgegeben . Anderenfalls wird der rekursive Aufruf ausgeführt .

Die zweite Form ist die indirekte Rekursion, wenn also beispielsweise Funktion A eine Funktion B aufruft, die dann wieder Funktion A aufruft. Beim Lösen von Aufgabenstellungen spielt indirekte Rekursion kaum eine Rolle und wird hier nicht näher betrachtet.

6.2 Start- und Endrekursion

Bevor wir uns mit dem Hauptkonzept befassen, müssen Sie den Unterschied zwischen Startrekursion (englisch head recursion) und Endrekursion (englisch tail recursion) verstanden haben. Bei einer Startrekursion erfolgt der rekursive Aufruf, bevor irgendwelche anderen Anweisungen in der Funktion ausgeführt werden (eben beim Start der Funktion). Bei einer Endrekursion verhält es sich umgekehrt: Die Anweisungen in der Funktion werden vor dem rekursiven Aufruf ausgeführt, der am Ende der Funktion steht. Die Entscheidung für eine dieser Formen der Rekursion mag etwas willkürlich erscheinen, kann jedoch den entscheidenden Unterschied ausmachen. Betrachten wir nun zwei Aufgabenstellungen, um diesen Unterschied besser zu verstehen.

Aufgabe: Wie viele Papageien?

Die Passagiere der Tropenparadies-Eisenbahn (TPE) genießen den Anblick Dutzender farbenprächtiger Papageien sehr, wenn sie während der Fahrt aus dem Fenster sehen. Die Eisenbahngesellschaft besitzt daher ein ausgeprägtes Interesse an der Gesundheit des örtlichen Papageienbestands und beschließt, eine Zählung der Papageien durchzuführen, die von den verschiedenen Bahnsteigen entlang der Hauptstrecke aus sichtbar sind. Die Bahnhöfe sind mit in der Vogelzählung erfahrenen TPE-Mitarbeitern besetzt (siehe Abbildung 6.1). Leider wird das Vorhaben durch die primitive Telefonanlage erschwert. Auf jedem Bahnhof lässt sich nur der unmittelbare Nachbarbahnhof anrufen. Wie kann die Gesamtzahl der Papageien zum Hauptbahnhof übermittelt werden?

Abb. 6.1: Die Mitarbeiter der fünf Bahnhöfe können nur mit ihren unmittelbaren Nachbarn kommunizieren.

Nehmen wir an, Art im Hauptbahnhof entdeckt 7 Papageien, Belinda 5, Cory 3, Debbie 10 und Evan, im letzten Bahnhof, zählt 2 Papageien. Es gibt somit insgesamt 27 Papageien. Nun stellt sich die Frage, wie die TPE-Angestellten zusammenarbeiten müssen, um Art dies mitzuteilen. Zur Lösung dieser Aufgabe ist eine Kommunikationskette vom Hauptbahnhof bis zur Endstation und zurück erforderlich. Die Mitarbeiter auf den einzelnen Bahnhöfen werden aufgefordert, die Papageien zu zählen und ihr Beobachtungsergebnis weiterzuleiten. Es gibt jedoch zwei unterschiedliche Lösungswege für diese Kommunikationskette, die der Start- bzw. Endrekursion bei der Programmierung entsprechen.

6.2.1 Lösungsweg 1

Bei diesem Lösungsweg zählen wir während der fortschreitenden Kommunikation die Anzahl der Papageien kontinuierlich mit. Bei der Anfrage an den folgenden Kollegen gibt jeder Mitarbeiter die Anzahl der bislang gezählten Papageien weiter. Wenn die Endstation erreicht ist, weiß Evan als Erster, wie viele Papageien es insgesamt gibt. Er gibt diese Information, wie in Abbildung 6.2 dargestellt, an Debbie weiter, Debbie informiert Cory usw.

Abb. 6.2: Nummerierung der für Lösungsweg 1 erforderlichen Schritte

Art zählt die Papageien in der Umgebung seines Bahnhofs. Er entdeckt 7 Papageien.

Art an Belinda: „Hier beim Hauptbahnhof sind 7 Papageien.”

Belinda zählt 5 Papageien in ihrer Nähe, was eine Gesamtsumme von 12 Papageien ergibt.

Belinda an Cory: „In der Umgebung der beiden ersten Bahnhöfe gibt es 12 Papageien.”

Cory zählt 3 Papageien.

Cory an Debbie: „In der Umgebung der ersten drei Bahnhöfe gibt es 15 Papageien.”

Debbie zählt 10 Papageien.

Debbie an Evan: „In der Umgebung der ersten vier Bahnhöfe gibt es 25 Papageien.”

Evan zählt 2 Papageien und weiß nun, dass es insgesamt 27 sind.

Evan an Debbie: „Es gibt insgesamt 27 Papageien.”

Debbie an Cory: „Es gibt insgesamt 27 Papageien.”

Cory an Belinda: „Es gibt insgesamt 27 Papageien.”

Belinda an Art: „Es gibt insgesamt 27 Papageien.”

Dieser Lösungsweg ist analog zur Endrekursion. Der rekursive Aufruf findet nach der Verarbeitung statt, ist also der letzte Schritt in der Funktion. In der obigen Kommunikationskette wird zunächst die Arbeit erledigt (Papageien zählen und aufsummieren), bevor der Anruf des nächsten Mitarbeiters erfolgt. Sämtliche Arbeiten finden statt, während die Kommunikationsvorgänge weiter nach außen wandern. Die einzelnen Arbeitsschritte der Mitarbeiter sehen wie folgt aus:

Zählen der vom eigenen Bahnhof aus sichtbaren Papageien.

Hinzufügen des Resultats zur vom vorhergehenden Bahnhof gemeldeten Gesamtzahl.

Anruf des nachfolgenden Bahnhofs und Weitergabe der Gesamtzahl der Papageien.

Warten auf den Rückruf vom nachfolgenden Bahnhof, der die endgültige Gesamtzahl meldet und Weitergabe dieser Zahl an den vorhergehenden Bahnhof.

6.2.2 Lösungsweg 2

Hier findet die Summierung der Papageien vom anderen Ende her statt. Jeder Mitarbeiter erkundigt sich beim Kollegen im nachfolgenden Bahnhof, wie viele Papageien es von dessen Bahnhof bis zur Endstation gibt. Wenn ein Rückruf erfolgt, fügt der Mitarbeiter die Zahl der Papageien in der Umgebung des eigenen Bahnhofs hinzu und gibt das Resultat weiter (siehe Abbildung 6.3).

Abb. 6.3: Nummerierung der für Lösungsweg 2 erforderlichen Schritte

Art an Belinda: „Wie viele Papageien gibt es von deinem Bahnhof bis zur Endstation?”

Belinda an Cory: „Wie viele Papageien gibt es von deinem Bahnhof bis zur Endstation?”

Cory an Debbie: „Wie viele Papageien gibt es von deinem Bahnhof bis zur Endstation?”.

Debbie an Evan: „Wie viele Papageien gibt es von deinem Bahnhof bis zur Endstation?”

Evan ist in der Endstation und zählt 2 Papageien.

Evan an Debbie: „Die Gesamtzahl der Papageien hier an der Endstation ist 2.”

Debbie zählt 10 Papageien in der Umgebung ihres Bahnhofs. Die Gesamtsumme ist also 12.

Debbie an Cory: „Die Gesamtzahl der Papageien von hier bis zur Endstation ist 12.”

Cory zählt 3 Papageien.

Cory an Belinda: „Die Gesamtzahl der Papageien von hier bis zur Endstation ist 15.”

Belinda zählt 5 Papageien.

Belinda an Art: „Die Gesamtzahl der Papageien von hier bis zur Endstation ist 20.”

Art zählt 7 Papageien in der Nähe des Hauptbahnhofs und errechnet eine Summe von 27 Papageien.

Dieser Lösungsweg ist analog zur Startrekursion. Der rekursive Aufruf findet vor der Verarbeitung statt und ist der erste Schritt in der Funktion. Der Anruf des nachfolgenden Bahnhofs erfolgt vor dem Zählen von Papageien und dem Summieren. Die Arbeit wird so lange verschoben, bis die nachfolgenden Bahnhöfe ihre Gesamtzahlen vermelden. Die einzelnen Arbeitsschritte der Mitarbeiter sehen wie folgt aus:

Anruf des nachfolgenden Bahnhofs.

Zählen der vom eigenen Bahnhof aus sichtbaren Papageien.

Hinzufügen des Resultats zur vom nachfolgenden Bahnhof gemeldeten Gesamtzahl.

Weitergabe dieser Zahl an den vorhergehenden Bahnhof.

Vielleicht sind Ihnen zwei der praktischen Auswirkungen der beiden verschiedenen Lösungswege aufgefallen. Beim ersten Lösungsweg erfahren alle Mitarbeiter die Gesamtzahl der Papageien, während beim zweiten Lösungsweg nur Art im Hauptbahnhof das Endergebnis kennt. Allerdings ist Art auch der Einzige, der dieses Resultat benötigt.

Die zweite praktische Auswirkung erlangt größere Bedeutung für unsere Analyse, wenn wir uns der eigentlichen Umsetzung in Programmcode zuwenden. Beim ersten Lösungsweg gibt jeder Mitarbeiter eine laufende Summe an den nachfolgenden Bahnhof weiter. Beim zweiten Lösungsweg hingegen erbittet jeder Mitarbeiter Informationen vom nachfolgenden Bahnhof, ohne dabei selbst Informationen weiterzugeben. Dieser Effekt ist typisch für eine Startrekursion. Da der rekursive Aufruf am Anfang der Funktion erfolgt, bevor irgendetwas anderes erledigt wird, gibt es auch keine neuen Informationen, die man dem Aufruf übergeben könnte. Allgemein ausgedrückt muss dem rekursiven Aufruf bei einer Startrekursion nur ein Minimum an Daten übergeben werden. Sehen wir uns nun eine weitere Aufgabe an.

Aufgabe: Wer ist unser bester Kunde?

Der Geschäftsführer der Delegieren & Co. GmbH möchte in Erfahrung bringen, welcher von acht Kunden seiner Firma den meisten Umsatz bringt. Es gibt jedoch zwei Umstände, die diese ansonsten einfache Aufgabe erschweren. Erstens muss zum Ermitteln des Umsatzes die gesamte Akte des Kunden durchgesehen werden, wobei Dutzende von Aufträgen und Rechnungen miteinander abzugleichen sind. Zweitens lieben es die Mitarbeiter der Delegieren & Co. GmbH, wie der Name bereits vermuten lässt, Aufgaben zu delegieren, und geben ihre Arbeit so oft wie möglich an Untergebene weiter. Damit die Situation nicht aus dem Ruder läuft, hat der Geschäftsführer Regeln aufgestellt: Wenn sie Arbeit delegieren möchten, müssen sie einen Teil der Arbeit selbst erledigen und dem anderen Mitarbeiter weniger Arbeit auftragen, als an sie selbst delegiert wurde.

In Tabelle 6.1 sind die Mitarbeiter der Delegieren & Co. GmbH aufgeführt; die Kunden finden Sie in Tabelle 6.2.

Berufsbezeichnung | Dienstrang
Geschäftsführer | 1
Stellvertretender Geschäftsführer | 2
Berater | 3
Assistent | 4
Trainee | 5
Praktikant | 6

Tabelle 6.1: Die Mitarbeiter der Delegieren & Co. GmbH

Kundennummer | Umsatz
Nr. 0001 | 172.000 €
Nr. 0002 | 68.000 €
Nr. 0003 | 193.000 €
Nr. 0004 | 13.000 €
Nr. 0005 | 256.000 €
Nr. 0006 | 99.000 €

Tabelle 6.2: Kunden der Delegieren & Co. GmbH

Mit den sechs Akten der Kunden wird nun, entsprechend den Regeln für das Delegieren von Aufgaben, Folgendes geschehen. Der Geschäftsführer nimmt sich eine der Akten und ermittelt den Umsatz des Kunden. Die anderen fünf Akten gibt er an den stellvertretenden Geschäftsführer weiter. Der bearbeitet eine der Akten selbst und übergibt vier Akten an den Berater. Das geht so weiter, bis beim sechsten Mitarbeiter, dem Praktikanten, eine Akte ankommt, die er bearbeiten muss, da es keine weitere Möglichkeit gibt, Aufgaben zu delegieren.

Abbildung 6.4 zeigt die Kommunikationsvorgänge und die Arbeitsteilung. Wie beim vorhergehenden Beispiel gibt es auch hier zwei unterschiedliche Lösungswege.

Abb. 6.4: Nummerierung der erforderlichen Schritte beim Ermitteln des Kunden mit höchstem Umsatz für Lösungsweg 1 (a) und Lösungsweg 2 (b)

6.2.3 Lösungsweg 1

Bei diesem Lösungsweg teilt der Mitarbeiter bei der Übergabe der verbleibenden Akten auch den bislang höchsten ermittelten Umsatz mit. Der fragliche Mitarbeiter muss also, bevor er die Akten selbst weitergibt, den Umsatz für eine der Akten ausrechnen und mit dem bislang höchsten Umsatz vergleichen. Es folgt ein Beispiel, wie sich diese Vorgänge in der Praxis abspielen könnten.

Der Geschäftsführer ermittelt den Umsatz des Kunden Nr. 0001, der sich auf 172.000 € beläuft.

Geschäftsführer an stellvertretenden Geschäftsführer: „Der bislang höchste Umsatz beträgt 172.000 €, Kunde Nr. 0001. Nimm die fünf Akten hier und ermittle den insgesamt höchsten Umsatz.”

Der stellvertretende Geschäftsführer errechnet den Umsatz für den Kunden Nr. 0002, immerhin 68.000 €. Der bislang höchste Umsatz ist also nach wie vor 172.000 €, erzielt durch den Kunden Nr. 0001.

Stellvertretender Geschäftsführer an Berater: „Der bislang höchste Umsatz beträgt 172.000 €, Kunde Nr. 0001. Nimm die vier Akten hier und ermittle den insgesamt höchsten Umsatz.”

Der Berater rechnet den Umsatz für den Kunden Nr. 0003 aus, 193.000 €, also ein neuer Höchstwert.

Berater an Assistent: „Der bislang höchste Umsatz beträgt 193.000 € für Kunde Nr. 0003. Nimm die drei Akten hier und ermittle den insgesamt höchsten Umsatz.”

Der Assistent errechnet den Umsatz für den Kunden Nr. 0004, der aber nur 13.000 € ausmacht. Der bislang höchste Umsatz bleibt daher 193.000 €, erreicht durch den Kunden Nr. 0003.

Assistent an Trainee: „Der bislang höchste Umsatz beträgt 193.000 € für Kunde Nr. 0003. Nimm die beiden Akten hier und ermittle den insgesamt höchsten Umsatz.”

Der Trainee berechnet den Umsatz für den Kunden Nr. 0005 und kommt auf satte 256.000 €, wieder ein neuer Höchstwert.

Trainee an Praktikant: „Der bislang höchste Umsatz beträgt 256.000 €, Kunde Nr. 0005. Nimm die Akte hier und ermittle den insgesamt höchsten Umsatz.”

Der Praktikant stellt fest, dass der Umsatz für den Kunden Nr. 0006 99.000 € beträgt. Die 256.000 € Umsatz des Kunden Nr. 0005 bleiben also der höchste Umsatz.

Praktikant an Trainee: „Der höchste Umsatz beträgt 256.000 €, Kunde Nr. 0005.”

Trainee an Assistent: „Der höchste Umsatz beträgt 256.000 €, Kunde Nr. 0005.”

Assistent an Berater: „Der höchste Umsatz beträgt 256.000 €, Kunde Nr. 0005.”

Berater an stellvertretenden Geschäftsführer: „Der höchste Umsatz beträgt 256.000 €, Kunde Nr. 0005.”

Stellvertretender Geschäftsführer an Geschäftsführer: „Der höchste Umsatz beträgt 256.000 €, Kunde Nr. 0005.”

Dieser Lösungsweg, der in Abbildung 6.4 (a) dargestellt ist, beruht auf einer Endrekursion. Jeder Mitarbeiter prüft eine der Akten und vergleicht den ermittelten Umsatz mit dem bislang höchsten bekannten Umsatz. Danach gibt er sein Resultat an den ihm unterstellten Mitarbeiter weiter. Die Rekursion, hier also das Abwälzen der Arbeit, geschieht nach den anderen Tätigkeiten. Die Aufgabenliste eines Mitarbeiters sieht dabei wie folgt aus:

Ermittlung des Umsatzes eines Kunden anhand seiner Akte

Vergleich des ermittelten Umsatzes mit dem bislang höchsten durch die Vorgesetzten errechneten Umsatz

Weitergabe der verbleibenden Akten an einen Untergebenen und Mitteilen des bislang höchsten Umsatzes

Weiterleitung des höchsten Umsatzes an den Vorgesetzten, wenn der Untergebene diesen meldet

6.2.4 Lösungsweg 2

Hier legt jeder Mitarbeiter anfangs eine der Akten beiseite und gibt die verbleibenden an den Untergebenen weiter. Dieser muss nun nicht den höchsten Umsatz aller Akten ermitteln, sondern nur denjenigen der ihm anvertrauten Akten. Das vereinfacht, wie bei der ersten Beispielaufgabe, die Anfrage. Die Kommunikationsvorgänge sähen dann etwa so aus:

Geschäftsführer an stellvertretenden Geschäftsführer: „Nimm die fünf Akten hier und ermittle den höchsten Umsatz.”

Stellvertretender Geschäftsführer an Berater: „Nimm die vier Akten hier und ermittle den höchsten Umsatz.”

Berater an Assistent: „Nimm die drei Akten hier und ermittle den höchsten Umsatz.”

Assistent an Trainee: „Nimm die beiden Akten hier und ermittle den höchsten Umsatz.”

Trainee an Praktikant: „Nimm die Akte hier und ermittle den Umsatz.”

Der Praktikant berechnet den Umsatz des Kunden Nr. 0006, der 99.000 € beträgt.

Praktikant an Trainee: „Der Umsatz für Kunde Nr. 0006 ist 99.000 €.”

Der Trainee ermittelt nun den Umsatz für den Kunden Nr. 0005, der 256.000 € beträgt. Dies ist der höchste Umsatz, den dieser Mitarbeiter kennt.

Trainee an Assistent: „Der höchste Umsatz meiner Akten ist 256.000 €, Kunde Nr. 0005.”

Der Assistent berechnet den Umsatz für den Kunden Nr. 0004, der 13.000 € beträgt. Der höchste Umsatz, den dieser Mitarbeiter kennt, ist 256.000 €, Kunde Nr. 0005.

Assistent an Berater: „Der höchste Umsatz meiner Akten ist 256.000 €, Kunde Nr. 0005.”

Der Berater berechnet den Umsatz für den Kunden Nr. 0003, der 193.000 € beträgt. Der höchste Umsatz, den dieser Mitarbeiter kennt, ist 256.000 €, Kunde Nr. 0005.

Berater an stellvertretenden Geschäftsführer: „Der höchste Umsatz meiner Akten ist 256.000 €, Kunde Nr. 0005.”

Der stellvertretende Geschäftsführer berechnet den Umsatz für den Kunden Nr. 0002, der sich auf 68.000 € beläuft. Der höchste Umsatz, den dieser Mitarbeiter kennt, ist 256.000 €, Kunde Nr. 0005.

Stellvertretender Geschäftsführer an Geschäftsführer: „Der höchste Umsatz meiner Akten ist 256.000 €, Kunde Nr. 0005.”

Der Geschäftsführer ermittelt den Umsatz für den Kunden Nr. 0001, der 172.000 € beträgt. Der höchste Umsatz, den dieser Mitarbeiter kennt, ist 256.000 €, Kunde Nr. 0005.

Dieser Lösungsweg, der in Abbildung 6.4 (b) dargestellt ist, beruht auf einer Startrekursion. Zwar muss noch immer jeder Mitarbeiter eine der Akten prüfen und den ermittelten Umsatz mit dem bislang höchsten bekannten Umsatz vergleichen, allerdings wird diese Aufgabe so lange verschoben, bis der ihm unterstellte Mitarbeiter den höchsten Umsatz der verbliebenen Akten meldet. Die Aufgabenliste eines Mitarbeiters sieht dabei wie folgt aus:

Einbehalten einer Akte und Weitergabe der verbleibenden Akten an einen Untergebenen

Mitteilung des höchsten Umsatzes durch den Untergebenen

Berechnung des Umsatzes der einbehaltenen Akte

Vergleich der beiden Werte und Weiterleitung des höheren Umsatzes an den Vorgesetzten

Wie bei der Aufgabe „Wie viele Papageien?” muss bei einer Startrekursion jeder Mitarbeiter nur ein Minimum an Informationen an den Untergebenen weitergeben.

6.3 Das Hauptkonzept der Rekursion

Wir kommen nun zu dem, was ich als Hauptkonzept der Rekursion bezeichne. Tatsächlich haben Sie dieses Konzept schon in Aktion gesehen, wenn Sie sich die verschiedenen Schritte der Beispielaufgaben durchgelesen haben.

Wie das? Die Form beider Beispiele entspricht einer rekursiven Lösung. Jede Person in der Kommunikationskette führt dieselben Schritte mit einer immer kleineren Untermenge der ursprünglichen Daten aus. Wichtig ist hierbei, dass die Aufgabe an sich gar keine Rekursion erfordert.

Bei der ersten Aufgabe stellt jeder Mitarbeiter eine Anfrage an den nachfolgenden Bahnhof, und beim Beantworten dieser Anfrage führt der nachfolgende Mitarbeiter die gleichen Schritte aus wie der vorhergehende. In der Anfrage weist jedoch nichts darauf hin, dass ein Mitarbeiter diese speziellen Schritte ausführen muss. Als Art beim zweiten Lösungsweg Belinda anruft, bittet er sie darum, die Papageien von ihrem Bahnhof bis zur Endstation zu zählen, er gibt aber keine Methode vor, wie das zu geschehen hat. Hätte er darüber nachgedacht, wäre ihm vielleicht aufgefallen, dass Belinda die gleichen Schritte ausführen muss wie er selbst, aber darüber braucht er sich keine Gedanken zu machen. Damit Art seine Aufgabe erfüllen kann, muss Belinda ihm nur seine Anfrage korrekt beantworten.

Bei der zweiten Aufgabe delegiert jeder Mitarbeiter in ähnlicher Weise möglichst viele Aufgaben an einen Untergebenen. Der Assistent kennt den Trainee möglicherweise gut und geht davon aus, dass dieser – bis auf eine – alle Akten an den Praktikanten weitergibt. Es kann ihm allerdings vollkommen gleichgültig sein, ob der Trainee die Akten selbst bearbeitet oder diese an einen Untergebenen weiterleitet. Den Assistenten interessiert nur, dass der Trainee das korrekte Ergebnis liefert. Der Assistent hat natürlich nicht die Absicht, die Arbeit des Trainees zu wiederholen, und geht davon aus, dass das vom Trainee gelieferte Resultat stimmt, und verwendet dieses Ergebnis, um seinerseits die Anfrage des Beraters an ihn zu beantworten.

Wenn die Mitarbeiter Anfragen an andere stellen, sind sie bei beiden Aufgaben nur daran interessiert, was dabei herauskommt, nicht, wie das Resultat ermittelt wird. Eine Anfrage wird gestellt und eine Antwort wird empfangen. Dies ist das Hauptkonzept der Rekursion: Wenn Sie bei der Programmierung gewissen Konventionen folgen, können Sie so tun, als ob gar keine Rekursion auftritt. Sie können sogar durch einen einfachen Trick (siehe unten) eine iterative Implementierung in eine rekursive umwandeln, ohne sich ausdrücklich darüber Gedanken machen zu müssen, wie genau die Aufgabe eigentlich durch Rekursion gelöst wird. Sie werden im Laufe der Zeit ein intuitives Verständnis für die Funktionsweise rekursiver Lösungen entwickeln, aber bevor es so weit ist, können Sie durch dieses Konzept rekursive Lösungen anfertigen und Ihrem Code vertrauen.

Setzen wir dieses Konzept anhand eines Codebeispiels in die Praxis um!

Aufgabe: Berechnung der Summe eines Arrays von Ganzzahlen

Schreiben Sie eine rekursive Funktion, die ein Array von Ganzzahlen und die Größe des Arrays als Parameter entgegennimmt und die Summe der Ganzzahlen im Array zurückgibt.

Möglicherweise war Ihr erster Gedanke, dass diese Aufgabe auf triviale Weise iterativ lösbar ist. Tatsächlich werden wir von einer iterativen Lösung ausgehen:

int iterativeArraySum(int integers[], int size) {
    int sum = 0;
    for (int i = 0; i < size; i++) {
        sum += integers[i];
    }
    return sum;
}

Sie sind in Kapitel 3 schon ganz ähnlichem Code begegnet, die Funktion sollte daher leicht verständlich sein. Der nächste Schritt besteht darin, Code zu schreiben, der sich auf halbem Wege zwischen iterativer und der endgültigen rekursiven Lösung befindet. Wir behalten die iterative Lösung zunächst noch und fügen eine zweite Funktion hinzu, die wir als Disponent bezeichnen. Der Disponent wird den größten Teil der zu erledigenden Arbeit an die eben erstellte iterative Funktion übergeben und diese dazu verwenden, die Aufgabe insgesamt zu lösen. Beim Schreiben des Disponenten müssen wir zwei Regeln beachten:

Den einfachsten Fall muss der Disponent selbst erledigen, ohne die iterative Funktion aufzurufen.

Beim Aufruf der iterativen Funktion muss der Disponent eine einfachere Version der ursprünglichen Aufgabe übergeben.

Wenn wir die erste dieser Regeln anwenden, müssen wir entscheiden, was der einfachste Fall ist. Wenn der Parameter size den Wert 0 besitzt, wurde ein leeres Array übergeben und die Summe ist 0. Man könnte ebenfalls argumentieren, dass der einfachste Fall eintritt, wenn size den Wert 1 besitzt. In diesem Fall besäße das Array nur einen einzigen Wert, den wir als Summe zurückliefern könnten. Funktionieren würden beide Auslegungen, aber die erstgenannte ermöglicht es der Funktion, einen Spezialfall mitzuerledigen. Beachten Sie auch, dass die iterative Funktion ebenfalls ein korrektes Ergebnis liefert, falls size den Wert 0 besitzt und es vorzuziehen ist, diese Flexibilität beizubehalten.

Bei Anwendung der zweiten Regel müssen wir herausfinden, auf welche Weise der Disponent der iterativen Funktion eine einfachere Version der Aufgabe übergeben kann. Es gibt keine einfache Möglichkeit, ein kleineres Array zu übergeben, aber wir können size einen kleineren Wert zuweisen. Wenn dem Disponenten der Wert 10 für size übergeben wird, soll die Funktion die Summe von 10 Werten des Arrays berechnen. Übergibt der Disponent der iterativen Funktion den Wert 9 für size, fordert er die Summe der ersten 9 Werte des Arrays an. Der Disponent kann dann den letzten im Array verbliebenen Wert (den zehnten) hinzuaddieren, um die Summe aller 10 Werte zu errechnen. Durch die Reduzierung von size um 1 beim Aufruf der iterativen Funktion wird dieser der maximale Anteil der Arbeitslast aufgebürdet, während der Disponent nur den minimalen Anteil der Arbeit zu verrichten hat. Genau dies ist der gesuchte Lösungsansatz: Wie die Mitarbeiter der Delegieren & Co. GmbH vermeidet der Disponent so viel Arbeit wie möglich.

Zusammengefasst führt dies bei der vorliegenden Aufgabenstellung zu folgendem Code für den Disponenten:

int arraySumDelegate(int integers[], int size) {
    if (size == 0) return 0;
    int lastNumber = integers[size - 1];
    int allButLastSum = iterativeArraySum(integers, size - 1);
    return lastNumber + allButLastSum;
}

Die erste Anweisung setzt die erste Regel für Disponenten um: Sie überprüft, ob der einfachste Fall vorliegt, und erledigt ihn gegebenenfalls vollständig, indem sie 0 zurückgibt . Anderenfalls wird der nachfolgende Code ausgeführt, der die zweite Regel umsetzt. Der letzte Wert im Array wird in einer lokalen Variablen namens lastNumber gespeichert . Danach erfolgt der Aufruf der iterativen Funktion zur Berechnung der Summe aller anderen Werte im Array . Das Resultat wird in einer weiteren lokalen Variablen allButLastSum gespeichert, und schließlich gibt die Funktion die Summe dieser beiden Variablen zurück .

Wenn der Disponent korrekt programmiert ist, haben wir faktisch bereits eine rekursive Lösung erstellt. Sie sehen hier das Hauptkonzept der Rekursion in Aktion. Um diese iterative Lösung in eine rekursive umzuwandeln, ist nur noch ein einfacher weiterer Schritt notwendig: Der Disponent muss statt der iterativen Funktion sich selbst aufrufen. Dann können wir die iterative Funktion komplett entfernen.

int arraySumRecursive(int integers[], int size) {
   if (size == 0) return 0;
   int lastNumber = integers[size - 1];
   int allButLastSum = arraySumRecursive(integers, size - 1);
   return lastNumber + allButLastSum;
}

Hier wurden nur zwei Änderungen am ursprünglichen Code vorgenommen. Der Name der Funktion beschreibt die Arbeitsweise jetzt treffend , und die Funktion ruft nun statt der iterativen Funktion sich selbst auf . Die Programmlogik der Funktionen arraySumDelegate und arraySumRecursive ist identisch. Beide Funktionen prüfen, ob der einfachste Fall (ein leeres Array) vorliegt, in dem die Summe bereits bekannt ist, und geben dann 0 zurück. Anderenfalls berechnen beide Funktionen die Summe der Werte im Array durch einen Funktionsaufruf zur Berechnung der Summe aller Werte bis auf den letzten. Schließlich addieren beide Funktionen diesen letzten Wert zur zurückgelieferten Summe, um die Gesamtsumme zu erhalten. Der einzige Unterschied besteht darin, dass die erste Version der Funktion eine andere Funktion aufruft, während die rekursive Version sich selbst aufruft. Wie das Hauptkonzept der Rekursion hier zeigt, können wir diesen Unterschied außer Acht lassen, wenn wir uns beim Schreiben des Disponenten an die oben erläuterten Regeln halten.

Sie müssen den oben aufgeführten Schritten nicht wortwörtlich folgen, um Vorteile aus dem Hauptkonzept der Rekursion zu ziehen. Insbesondere werden Sie für gewöhnlich keine iterative Funktion programmieren, bevor Sie die rekursive Lösung implementieren. Das Programmieren einer iterativen Lösung, die letzten Endes keine Verwendung findet, als „Sprungbrett” für eine rekursive Lösung stellt schließlich zusätzliche Arbeit dar. Davon abgesehen finden rekursive Lösungen oft ohnehin in Situationen Anwendung, in denen iterative Lösungen schwierig sind. Sie können das Hauptkonzept der Rekursion aber auch umsetzen, ohne tatsächlich eine iterative Lösung zu programmieren. Entscheidend ist es, sich den rekursiven Aufruf als Aufruf einer anderen Funktion vorzustellen, ohne dabei die interne Arbeitsweise dieser Funktion zu beachten. Auf diese Weise können Sie der rekursiven Lösung den Schrecken der komplizierten rekursiven Logik nehmen.

6.4 Häufige Fehler

Wie Sie gerade gesehen haben, lassen sich rekursive Lösungen mit dem richtigen Ansatz oft ganz leicht erstellen. Es passiert jedoch genauso leicht, dass man sich eine fehlerhafte rekursive Implementierung ausdenkt oder eine rekursive Lösung erstellt, die zwar „funktioniert”, aber ziemlich plump ist. Die meisten der Probleme bei rekursiven Implementierungen entstehen durch zwei grundlegende Fehler: Entweder zerbricht man sich über die Aufgabenstellung viel zu sehr den Kopf oder aber man beginnt ohne einen klaren Plan mit der Implementierung.

Angehende Programmierer zerbrechen sich bei rekursiven Problemstellungen oft viel zu sehr den Kopf, weil mangelnde Erfahrung und fehlendes Vertrauen in die Rekursion dazu führt, dass ihnen eine Aufgabe schwieriger erscheint, als sie es tatsächlich ist. Auf diese Weise entstandener Code ist an übertriebener Vorsicht erkennbar. Eine rekursive Funktion unterscheidet dann beispielsweise mehrere verschiedene Spezialfälle, obwohl ein einziger ausgereicht hätte.

Wenn man zu früh mit der Implementierung beginnt, kann dies zu übertrieben kompliziertem „Rube Goldberg”-Code führen (Rube Goldberg ist der Erfinder extrem komplizierter mechanischer Maschinen, die nur einfachste Aufgaben ausführen), bei dem unvorhergesehene Wechselwirkungen Korrekturen erfordern, die dem ursprünglichen Code „übergestülpt” werden müssen.

Sehen wir uns einige der typischen Fehler und deren Vermeidung etwas näher an.

6.4.1 Zu viele Parameter

Wie zuvor erläutert, kann eine Startrekursion dabei helfen, die Daten zu reduzieren, die dem rekursiven Aufruf übergeben werden müssen, während eine Endrekursion dazu führen kann, dass der rekursive Aufruf zusätzliche Daten erfordert. Manche Programmierer verheddern sich dann bei der Endrekursion, weil sie die Schwierigkeiten überbewerten und zu früh mit der Implementierung beginnen.

Betrachten wir noch einmal die Aufgabe, die Summe der Werte eines Arrays von Ganzzahlen rekursiv zu berechnen. Nach dem Erstellen einer iterativen Lösung weiß der Programmierer, dass eine Variable für die Gesamtsumme erforderlich ist (in der von mir vorgestellten Lösung heißt diese Variable sum) und dass die Summierung beim ersten Element des Arrays beginnt. Stellt der Programmierer nun Überlegungen zur rekursiven Lösung an, denkt er dabei naturgemäß an eine Implementierung, die seine iterative Lösung möglichst einfach widerspiegelt. Also eine Implementierung, die eine Variable für die Gesamtsumme besitzt und die dem ersten rekursiven Aufruf das erste Element des Arrays übergibt. Bei diesem Ansatz muss der rekursiven Funktion allerdings die Gesamtsumme und der Index des nächsten Array-Elements, das verarbeitet werden soll, übergeben werden. Eine solche Lösung könnte so aussehen:

int arraySumRecursiveExtraParams(int integers[],
             int size, int sum, int currentIndex) {
    if (currentIndex == size) return sum;
    sum += integers[currentIndex];
    return arraySumRecursiveExtraParameters
           (integers, size, sum, currentIndex + 1);
}

Dieser Code ist zwar genauso kurz und bündig wie die andere rekursive Version, aber aufgrund der zusätzlichen Parameter sum und currentIndex semantisch erheblich komplizierter. Aus Sicht des Client-Codes sind die zusätzlichen Parameter bedeutungslos und müssen beim Aufruf auf null gesetzt werden, so wie in diesem Beispiel:

int a[10] = {20, 3, 5, 22, 7, 9, 14, 17, 4, 9};
int total = arraySumRecursiveExtraParameters(a, 10, 0, 0);

Durch die Verwendung einer Wrapper-Funktion, wie sie im nächsten Abschnitt beschrieben wird, lässt sich dieses Problem umgehen, aber da sich diese Parameter nicht vollständig eliminieren lassen, handelt es sich hierbei nicht um die beste Lösung. Sowohl die iterative Funktion zur Lösung dieser Aufgabenstellung als auch die ursprüngliche rekursive Funktion beantwortet die Frage „Wie groß ist die Summe des Arrays mit x Elementen?” Bei der zweiten rekursiven Funktion hingegen lautet die Frage „Wie groß ist die Summe des Arrays, wenn es x Elemente hat, man bei Element y anfängt zu summieren und die Summe der vorhergehenden Elemente z beträgt?”

Wählt man die Funktionsparameter aus, bevor man Überlegungen zur Rekursion anstellt, lässt sich das Problem zu vieler Parameter vermeiden. Mit anderen Worten: Zwingen Sie sich dazu, dieselben Parameter zu verwenden, die Sie bei einer iterativen Lösung gewählt hätten. Wenn Sie dem Hauptkonzept der Rekursion im Detail folgen und tatsächlich zunächst iterative Funktionen schreiben, gehen Sie diesem Problem automatisch aus dem Weg. Falls Sie diesen formalen Schritt überspringen, können Sie sich diese Überlegungen dennoch zunutze machen, indem Sie die Parameterliste ausarbeiten, die Sie bei einer iterativen Funktion erwarten würden.

6.4.2 Globale Variablen

Die Vermeidung zu vieler Parameter verleitet Programmierer manchmal dazu, einen anderen Fehler zu begehen, nämlich für die Datenübergabe von einem rekursiven Aufruf zum nächsten globale Variablen zu verwenden. Im Allgemeinen ist es eine schlechte Gepflogenheit, globale Variablen zu verwenden, obwohl dies aus Geschwindigkeitsgründen manchmal zulässig sein kann. In rekursiven Funktionen sollte auf globale Variablen, wenn irgend möglich, verzichtet werden. Sehen wir uns eine konkrete Aufgabe an, um zu verstehen, warum Programmierer sich den Gebrauch globaler Variablen schönreden. Nehmen wir an, wir möchten eine rekursive Funktion zum Zählen der Nullen in einem Array von Ganzzahlen schreiben. Ein iterative Lösung ist schnell gefunden:

int zeroCountIterative(int numbers[], int size) {
   int count = 0;
   for (int i = 0; i < size; i++) {
      if (numbers[i] == 0) count ++;
   }
   return count;
}

Der logische Aufbau des Codes ist nicht weiter kompliziert. Wir durchlaufen das Array vom ersten bis zum letzten Element und zählen dabei in einer lokalen Variablen count die Anzahl der Nullen. Wenn wir diese Funktion vor unserem geistigen Auge haben, könnte man beim Schreiben der rekursiven Funktion auf den Gedanken kommen, dass diese ebenfalls einen solchen Zähler benötigt. Wir können jedoch count in der rekursiven Funktion nicht einfach als lokale Variable deklarieren, weil es sich dann bei jedem rekursiven Aufruf um eine neue Variable handelt. Man ist daher versucht, count als globale Variable zu deklarieren:

int count;
int zeroCountRecursive(int numbers[], int size) {
   if (size == 0) return count;
   if (numbers[size - 1] == 0) count++;
   zeroCountRecursive(numbers, size - 1);
}

Der Code funktioniert, aber die globale Variable ist völlig unnötig und verursacht die typischen Probleme, die globale Variablen nun einmal mit sich bringen, wie mangelhafte Verständlichkeit und schwierigere Pflege des Codes. Manche Programmierer versuchen, das Problem durch eine lokale statische Variable zu entschärfen:

int zeroCountStatic(int numbers[], int size) {
   static int count = 0;
   if (size == 0) return count;
   if (numbers[size - 1] == 0) count++;
   zeroCountStatic(numbers, size - 1);
}

In C++ behält eine als static deklarierte Variable ihren Wert von einem Funktionsaufruf zum nächsten bei, die lokale statische Variable count verhält sich wie die globale Variable der vorhergehenden Version. Wo also ist das Problem? Die Initialisierung der Variablen mit dem Wert 0 findet nur beim ersten Aufruf der Funktion statt. Dies ist bei einer statischen Deklaration notwendig, damit diese irgendeinen Nutzen hat, aber es bedeutet, dass die Funktion nur beim allerersten Aufruf ein korrektes Ergebnis liefert. Wenn man diese Funktion zwei Mal aufruft, etwa mit einem Array, das drei Nullen besitzt, und ein weiteres Mal mit einem Array, das fünf Nullen besitzt, wäre das Resultat acht, da count seinen Wert nach dem ersten Aufruf beibehält. (Gleiches gilt übrigens auch bei Verwendung einer globalen Variablen, sofern diese nicht vor jedem Aufruf wieder zurückgesetzt wird.)

Zur Vermeidung der globalen Variablen kommt wieder das Hauptkonzept der Rekursion zum Einsatz. Wir dürfen annehmen, dass ein rekursiver Aufruf mit einem kleineren Wert für size den korrekten Wert zurückliefert, und damit den Wert für das gesamte Array berechnen. Dies führt zu einer Lösung, die eine Startrekursion verwendet:

int zeroCountRecursive(int numbers[], int size) {
   if (size == 0) return 0;
   int count = zeroCountRecursive(numbers, size - 1);
   if (numbers[size - 1] == 0) count++;
   return count;
}

In dieser Version gibt es zwar noch immer eine lokale Variable count , es wird jedoch keinerlei Versuch unternommen, deren Wert von einem Funktionsaufruf zum nächsten zu bewahren. Stattdessen speichert sie den Rückgabewert des rekursiven Aufrufs. Danach erhöhen wir den Wert bei Bedarf , bevor er zurückgegeben wird .

6.5 Rekursion bei dynamischen Datenstrukturen

Rekursion findet häufig bei dynamischen Datenstrukturen wie verketteten Listen, Binärbäumen und Graphen Anwendung. Je komplizierter solch eine Struktur ist, umso mehr profitiert man beim Programmieren von einer rekursiven Lösung. Die Verarbeitung komplizierter Datenstrukturen ist nicht selten damit vergleichbar, sich einen Weg durch ein Labyrinth zu bahnen, und die Rekursion gestattet es uns, die vergangenen Schritte der Verarbeitung zurückzuverfolgen.

6.5.1 Rekursion und verkettete Listen

Sehen wir uns zunächst die einfachste dynamische Datenstruktur an, eine verkettete Liste. Wir nehmen im Folgenden an, dass unsere verkettete Liste die denkbar einfachste Knotenstruktur besitzt, nämlich nur eine einzelne Ganzzahl zur Aufnahme von Daten. Dies sind die Typdeklarationen:

struct listNnode {
    int data;
    listNode * next;
};
typedef listNode * listPtr;

Die Anwendung des Hauptkonzepts der Rekursion auf eine einfach verkettete Liste geschieht, unabhängig von der speziellen Aufgabe, nach den üblichen allgemeinen Richtlinien. Bei der Rekursion müssen wir die ursprüngliche Aufgabenstellung in Teilaufgaben zerlegen, um dem rekursiven Aufruf eine vereinfachte Version der Aufgabe übergeben zu können. Für die Zerlegung einer einfach verketteten Liste gibt es nur eine praktikable Möglichkeit: in den ersten Knoten und in den Rest der Liste.

Abbildung 6.5 zeigt eine in ungleiche Teile zerlegte Beispielliste: den ersten Knoten und alle anderen Knoten der Liste. Gedanklich können wir diesen „Rest der Liste” als eine eigene Liste auffassen, die mit dem zweiten Knoten der ursprünglichen Liste beginnt. Erst diese Betrachtungsweise sorgt dafür, dass die Rekursion reibungslos funktioniert.

Abb. 6.5: Zerlegung einer Liste in den ersten Knoten und den „Rest der Liste”

Es ist hier jedoch ein weiteres Mal nicht erforderlich, dass wir uns sämtliche Schritte ausmalen, damit die Rekursion funktioniert. Wenn es darum geht, eine rekursive Funktion zur Verarbeitung einer verketteten Liste zu programmieren, dürfen wir die folgende Sichtweise einnehmen: Es gibt einen ersten Knoten, um den wir uns kümmern müssen, und es gibt den Rest der Liste, der uns gleichgültig ist. Diese Geisteshaltung soll Abbildung 6.6 verdeutlichen.

Abb. 6.6: So sollte sich ein Programmierer, der Rekursion verwendet, die Liste vorstellen: als ersten Knoten und Rest der Liste, ein diffuses Gebilde, das dem rekursiven Aufruf aufgebürdet wird.

Nachdem die Arbeitsaufteilung geklärt ist, können wir festlegen, dass die rekursive Verarbeitung einer einfach verketteten Liste nach dem unten stehenden allgemeinen Plan erfolgen soll. Für eine verkettete Liste L und eine Frage F soll gelten:

Wenn L minimal ist, weisen wir direkt einen Standardwert zu. Ansonsten weiter mit Schritt 2.

Verwende den rekursiven Aufruf, um eine Antwort auf F für den „Rest von L” (die Liste, die mit dem zweiten Knoten von L beginnt) zu erhalten.

Überprüfung des Werts im ersten Knoten von L.

Die Resultate der beiden vorhergehenden Schritte werden dazu verwendet, um F für die gesamte Liste L zu beantworten.

Wie Sie sehen, handelt es sich hier nur um die gradlinige Anwendung des Hauptkonzepts der Rekursion mit den praxisbedingten Einschränkungen durch die Zerlegung einer verketteten Liste. Nun wollen wir diese Vorlage auf eine konkrete Aufgabenstellung anwenden.

Aufgabe: Negative Zahlen in einer einfach verketteten Liste zählen

Schreiben Sie eine rekursive Funktion, die eine einfach verkettete Liste entgegennimmt, deren Datentyp eine Ganzzahl ist. Die Funktion gibt die Anzahl der negativen Zahlen in der Liste zurück.

Die Frage F, die wir beantworten möchten, lautet „Wie viele negative Zahlen befinden sich in der Liste?” Unser Plan kann daher folgendermaßen formuliert werden:

Wenn die Liste keine Knoten besitzt, ist die Anzahl 0. Ansonsten weiter mit Schritt 2.

Verwende den rekursiven Aufruf, um die Anzahl der negativen Zahlen im „Rest der Liste” zu ermitteln.

Überprüfung, ob der Wert des ersten Knotens der Liste negativ ist.

Die Resultate der beiden vorhergehenden Schritte werden dazu verwendet, die Anzahl negativer Zahlen in der gesamten Liste zu errechnen.

Die folgende Implementierung der Funktion ist unmittelbar aus diesem Plan abgeleitet:

int countNegative(listPtr head) {
    if (head == NULL) return 0;
    int listCount = countNegative(head->next);
    if (head->data < 0) listCount++;
    return listCount;
}

Beachten Sie, wie dieser Code den gleichen Richtlinien folgt wie die vorhergehenden Beispiele. Der Code zählt die negativen Zahlen in umgekehrter Reihenfolge, also vom Ende der Liste bis zu deren Anfang. Beachten Sie ebenfalls, dass der Code eine Startrekursion verwendet, denn der „Rest der Liste” wird vor dem ersten Knoten verarbeitet. Wir müssen daher, wie schon früher, weder dem rekursiven Aufruf zusätzliche Daten übergeben noch globale Variablen verwenden.

Die erste Regel für verkettete Listen „Wenn L minimal ist” wird in dieser konkreten Implementierung als „Wenn die Liste keine Knoten besitzt” ausgelegt, denn eine Liste ohne Knoten kann keine negativen Werte besitzen. Dennoch gibt es in manchen Fällen nicht für alle Listen ohne Knoten eine sinnvolle Antwort auf die Frage F. In diesem Fall ist eine Liste mit einem Knoten minimal. Nehmen wir an, die Frage lautet „Welche der Zahlen in der Liste ist am größten?” Diese Frage lässt sich für eine Liste ohne Werte nicht beantworten. Wenn Sie das nicht einsehen, stellen Sie sich vor, dass Sie ein Grundschullehrer sind, dessen Klasse zufällig nur aus Mädchen besteht. Wenn der Schuldirektor Sie nun fragt, wie viele Jungs aus Ihrer Klasse im Schulchor singen, könnten Sie einfach mit „Keiner” oder „Null” antworten, denn es gibt ja keine Jungs in Ihrer Klasse. Wenn Sie aber gefragt werden, welcher der Jungs in Ihrer Klasse am größten ist, könnten Sie keine sinnvolle Antwort geben, denn es müsste ja mindestens einen Jungen in Ihrer Klasse geben, damit es überhaupt einen größten Jungen gibt. Wenn eine Frage bezüglich einer Sammlung von Datensätzen in ähnlicher Weise mindestens einen Wert notwendig macht, um sinnvoll beantwortet zu werden, ist eine Datensammlung mit einem Datensatz minimal. Dessen ungeachtet möchten Sie in Ihrer Funktion möglicherweise irgendetwas zurückgeben, wenn dieser Fall eintritt, und sei es nur, um bei der Verwendung der Funktion flexibel reagieren zu können und um Programmabstürze zu verhindern.

6.5.2 Rekursion und Binärbäume

Keines der bislang untersuchten Beispiele verwendet mehr als einen rekursiven Aufruf. Bei komplizierteren Datenstrukturen kann es jedoch erforderlich sein, mehrere rekursive Aufrufe zu tätigen. Um dem nachzugehen, wollen wir nun eine Datenstruktur namens Binärbaum betrachten, in der jeder Knoten einen linken und einen rechten Kindknoten besitzt. Wir verwenden folgenden Datentyp:

struct treeNode {
    int data;
    treeNode * left;
    treeNode * right;
};
typedef treeNode * treePtr;

Da jeder Knoten des Baums auf zwei andere Knoten verweist, sind in Funktionen zur rekursiven Verarbeitung von Binärbäumen jeweils zwei rekursive Aufrufe erforderlich. Wir stellen uns eine verkettete Liste als aus zwei Teilen bestehend vor: einem ersten Knoten und dem Rest der Liste. Bei Anwendung der Rekursion kann man sich den Binärbaum als aus drei Teilen bestehend vorstellen: dem Knoten an der Spitze, dem sogenannten Wurzelknoten sowie all den Knoten, die vom Wurzelknoten nach links bzw. rechts abgehen. Diese heißen linker bzw. rechter Unterbaum (siehe Abbildung 6.7). Auf der Suche nach einer rekursiven Lösung konzentrieren wir uns dabei, ähnlich wie bei der verketteten Liste, nur auf das Vorhandensein der linken und rechten Unterbäume, nicht auf deren Inhalt (Abbildung 6.8).

Abb. 6.7: Wurzelknoten sowie linker und rechter Unterbaum des Binärbaums

Abb. 6.8: Ein Binärbaum, wie ein Programmierer, der Rekursion verwendet, ihn sich vorstellen sollte: ein Wurzelknoten mit linken und rechten Unterbäumen, deren Struktur nichts zur Sache tut

Wir wollen nun das Hauptkonzept der Rekursion auf den Binärbaum anwenden. Wir werden rekursive Funktionsaufrufe verwenden und setzen dabei voraus, dass diese korrekte Resultate liefern, ohne uns um die Details zu kümmern, wie genau das rekursive Verfahren funktioniert. Wie bei der verketteten Liste verwenden wir die naheliegende Zerlegung des Binärbaums, was zum folgenden allgemeinen Plan führt. Für eine Frage F zu einem Baum B soll gelten:

Wenn der Baum B von minimaler Größe ist, weisen wir direkt einen Standardwert zu. Ansonsten weiter mit Schritt 2.

Verwende einen rekursiven Aufruf, um eine Antwort auf F für den linken Unterbaum von B zu erhalten.

Verwende einen rekursiven Aufruf, um eine Antwort auf F für den rechten Unterbaum von B zu erhalten.

Überprüfung des Werts im Wurzelknoten von B.

Die Resultate der drei vorhergehenden Schritte werden dazu verwendet, um F für den gesamten Baum B zu beantworten.

Nun wenden wir den Plan auf ein konkretes Problem an.

Aufgabe: Suche nach dem größten Wert in einem Binärbaum

Schreiben Sie eine Funktion, die einen Binärbaum entgegennimmt, dessen Knoten Ganzzahlen enthalten. Die Funktion liefert den größten dieser Werte zurück.

Die Anwendung des allgemeinen Plans auf diese Aufgabenstellung führt zu folgenden Schritten:

Wenn der Wurzelknoten keine Kindknoten besitzt, geben Sie den Wert im Wurzelknoten zurück. Ansonsten weiter mit Schritt 2.

Verwenden Sie einen rekursiven Aufruf, um den größten Wert im linken Unterbaum von B zu suchen.

Verwenden Sie einen rekursiven Aufruf, um den größten Wert im rechten Unterbaum von B zu suchen.

Überprüfen Sie den Wert im Wurzelknoten.

Geben Sie den größten der in den drei vorhergehenden Schritten gefundenen Werte zurück.

Mit diesen Schritten vor Augen können wir unmittelbar den Code zur Lösung ableiten:

int maxValue(treePtr root) {
    if (root == NULL) return 0;
    if (root->right == NULL && root->left == NULL)
        return root->data;
    int leftMax = maxValue(root->left);
    int rightMax = maxValue(root->right);
    int maxNum = root->data;
    if (leftMax > maxNum) maxNum = leftMax;
    if (rightMax > maxNum) maxNum = rightMax;
    return maxNum;
}

Beachten Sie, dass der kleinstmögliche Baum bei dieser Aufgabenstellung ein einzelner Knoten ist (der Sicherheit halber wird jedoch der Fall eines leeren Baumes ebenfalls behandelt ). Das liegt daran, dass die gestellte Frage nur sinnvoll beantwortet werden kann, wenn es mindestens einen Wert gibt. Bedenken Sie auch die in der Praxis auftretenden Schwierigkeiten, wenn ein leerer Baum der Basisfall wäre. Welchen Wert sollen wir dann zurückliefern? Wenn wir 0 zurückgeben, besagt das implizit, dass es negative Werte im übrigen Baum gibt. Sind hingegen tatsächlich alle Werte im Baum negativ, wird fälschlicherweise 0 als größter Wert zurückgeliefert. Dieses Problem ließe sich umgehen, wenn wir die vom Absolutbetrag her größtmögliche negative Zahl zurückgeben, aber dann muss man höllisch aufpassen, wenn der Code an andere numerische Datentypen angepasst werden soll. Durch die Auswahl eines einzelnen Knotens als Basisfall stellen sich all diese Fragen gar nicht erst.

Der verbleibende Code ist unkompliziert. Wir verwenden Rekursion, um den größten Wert im linken und rechten Unterbaum zu finden, und benutzen dann eine Variante des „König der Murmelspieler”-Algorithmus, der im Buch durchgehend zum Einsatz kommt .

6.6 Wrapper-Funktionen

In den Beispielen dieses Kapitels kam bislang nur die rekursive Funktion selbst vor. In manchen Fällen kann es jedoch erforderlich sein, die rekursive Funktion durch eine zweite Funktion „vorzubereiten”. Dieser Fall tritt meistens beim Schreiben rekursiver Funktionen innerhalb von Klassen auf. Dadurch kann es zu einer Diskrepanz zwischen den Parametern der rekursiven Funktion und denen einer öffentlichen Methode der Klasse kommen. Da Klassen aber typischerweise Information Hiding verwenden, kann es vorkommen, dass der Client-Code keinen Zugriff auf die benötigten Daten oder Datentypen hat, die zur Ausführung der rekursiven Funktion erforderlich sind. Dieses Problem und dessen Lösung sind Thema des nächsten Beispiels.

Aufgabe: Anzahl der Blätter eines Binärbaums

Fügen Sie einer Klasse, die einen Binärbaum implementiert, eine öffentliche Methode hinzu. Die Methode liefert die Anzahl der Blätter (Knoten ohne Kindknoten) des Baums zurück und sollte Rekursion verwenden.

Wir skizzieren zunächst die Arbeitsweise dieser Klasse, bevor wir versuchen, eine Lösung des Problems zu implementieren. Der Einfachheit halber betrachten wir nur die relevanten Teile der Klasse und lassen Konstruktoren, Destruktor und andere Methoden, die es ermöglichen, den Binärbaum zu erstellen, außen vor, um uns ganz auf die rekursive Methode zu konzentrieren:

class binaryTree {
    public:
        int leafCount();
    private:
        struct binaryTreeNode {
            int data;
            binaryTreeNode * left;
            binaryTreeNode * right;
        };
        typedef binaryTreeNode * treePtr;
        treePtr _root;
};

Beachten Sie, dass die Funktion zum Zählen der Blätter keinen Parameter besitzt . Für die öffentliche Schnittstelle der Klasse ist dies genau richtig. Betrachten Sie z. B. einen Aufruf durch eine zuvor erstellte binaryTree-Instanz bt:

int numLeaves = bt.leafCount();

Wir fragen hier den Baum, wie viele Blätter er besitzt und welche Informationen, die er über sich selbst noch nicht besitzt, könnten wir ihm schon mitteilen? So stimmig dies für die Schnittstelle der Klasse ist, so falsch wäre es bei der rekursiven Implementierung. Was sollte sich denn ohne Parameter von einem rekursiven Aufruf zum nächsten ändern? In diesem Fall kann sich nichts ändern, es sei denn durch globale Variablen, die es aber, wie erwähnt, zu vermeiden gilt. Ändert sich jedoch nichts, kann die Rekursion weder fortfahren noch abbrechen.

Ein Ausweg lässt sich finden, indem man die rekursive Funktion zunächst so schreibt, als befände sie sich außerhalb der Klasse. Mit anderen Worten: Wir schreiben die Funktion zum Zählen der Blätter eines Binärbaums in gleicher Art und Weise, wie wir auch die Funktion zur Suche des größten Werts in einem Binärbaum geschrieben haben. Der einzige Parameter, den wir dabei übergeben müssen, ist ein Zeiger auf unsere Datenstruktur.

Hier bietet sich eine weitere Gelegenheit, das Hauptkonzept der Rekursion umzusetzen. Wie lautet in diesem Fall die Frage F? Wie viele Blätter besitzt der Baum? Die Anwendung des allgemeinen Plans führt zu Folgendem:

Wenn der Wurzelknoten keine Kindknoten besitzt, besitzt der Baum genau einen Knoten, der per definitionem ein Blatt ist, also geben Sie den Wert 1 zurück. Ansonsten weiter mit Schritt 2.

Verwenden Sie einen rekursiven Aufruf, um die Blätter im linken Unterbaum zu zählen.

Verwenden Sie einen rekursiven Aufruf, um die Blätter im rechten Unterbaum zu zählen.

In diesem Fall muss der Wurzelknoten nicht überprüft werden, denn wenn dieser Schritt erreicht ist, kann der Knoten unmöglich ein Blatt sein.

Geben Sie die Summe aus Schritt 2 und 3 zurück.

Die Übersetzung dieses Plans in Code ergibt Folgendes:

struct binaryTreeNode {
    int data;
    binaryTreeNode * left;
    binaryTreeNode * right;
};
typedef binaryTreeNode * treePtr;
int leafCount(treePtr rootPtr) {
    if (rootPtr == NULL) return 0;
    if (rootPtr->right == NULL && rootPtr->left == NULL)
        return 1;
    int leftCount = leafCount(rootPtr->left);
    int rightCount = leafCount(rootPtr->right);
    return leftCount + rightCount;
}

Der Code lässt sich unmittelbar aus dem Plan herleiten. Die Frage ist nun, wie man von dieser eigenständigen Funktion zu etwas in der Klasse Brauchbarem kommt. Dies ist der Punkt, an dem unachtsame Programmierer leicht in Schwierigkeiten geraten, weil sie glauben, dass eine globale Variable oder ein öffentlicher Startzeiger notwendig ist. Dem ist aber nicht so, wir können alles innerhalb der Klasse belassen. Der Trick besteht in der Verwendung einer Wrapper-Funktion. Zunächst übernehmen wir die eigenständige Funktion mit dem Parameter treePtr im privaten Abschnitt unserer Klasse. Nun schreiben wir eine öffentliche Funktion, die Wrapper-Funktion, in die unsere private Funktion „eingewickelt” wird. Da die öffentliche Funktion auf die private Membervariable _root zugreifen darf, kann sie diese an die rekursive Funktion durchreichen und dem Client das Resultat zurückliefern. Das sieht dann so aus:

class binaryTree {
    public:
        int publicLeafCount ();
    private:
        struct binaryTreeNode {
            int data;
            binaryTreeNode * left;
            binaryTreeNode * right;
        };
        typedef binaryTreeNode * treePtr;
        treePtr _root;
        int privateLeafCount(treePtr rootPtr);
};
int binaryTree::privateLeafCount(treePtr rootPtr) {
    if (rootPtr == NULL) return 0;
    if (rootPtr->right == NULL && rootPtr->left == NULL)
        return 1;
    int leftCount = privateLeafCount(rootPtr->left);
    int rightCount = privateLeafCount(rootPtr->right);
    return leftCount + rightCount;
}
int binaryTree::publicLeafCount() {
     return privateLeafCount(_root);
}

C++ würde es zwar gestatten, für beide Funktionen den gleichen Namen zu verwenden, aber der Deutlichkeit halber habe ich unterschiedliche Bezeichnungen gewählt, um zwischen der öffentlichen und privaten Funktion zum Blätterzählen zu unterscheiden. Der Code in privateLeafCount ist genau derselbe wie in der ursprünglichen eigenständigen Funktion leafCount. Die Wrapper-Funktion publicLeafCount macht nichts weiter, als privateLeafCount aufzurufen und dabei die private Membervariable _root zu übergeben. Danach liefert sie das Ergebnis zurück . Sie bringt letztlich die Rekursion erst „in Gang”. Beim Programmieren von rekursiven Funktionen innerhalb einer Klasse sind Wrapper-Funktionen eine große Hilfe, sie können aber auch immer dann eingesetzt werden, wenn es eine Diskrepanz zwischen der für eine Funktion benötigten und der für den Aufrufer erwünschten Parameterliste gibt.

6.7 Wann wird Rekursion verwendet?

Angehende Programmierer sind oft erstaunt, dass man sich überhaupt mit Rekursion befassen muss. Häufig ist ihnen bereits bekannt, dass sich jedes denkbare Programm mittels grundlegender Kontrollstrukturen, wie etwa Bedingungen (if-Anweisungen) und Iterationen (for- und while-Schleifen), formulieren lässt. Schnell gelangen sie zur Meinung: Wenn es so viel schwieriger ist, statt der elementaren Kontrollstrukturen Rekursion anzuwenden, diese also eigentlich überflüssig ist, sollte man das Thema vielleicht einfach ignorieren.

Es gibt mehrere Gründe, hier zu widersprechen. Erstens hilft die rekursive Programmierung den Programmierern dabei, rekursiv zu denken. Diese Fähigkeit ist in der Welt der Informatik, wie z. B. beim Compilerdesign, unverzichtbar. Zweitens gibt es einige Programmiersprachen, die Rekursion zwingend benötigen, weil ihnen bestimmte Kontrollstrukturen fehlen. Unverfälschte Versionen der Sprache Lisp benötigen beispielsweise für fast alle nicht-trivialen Funktionen Rekursion.

Es bleibt allerdings die Frage: Wenn ein Programmierer sich lange genug mit Rekursion beschäftigt hat, um „auf den Trichter zu kommen”, und eine Sprache mit vollem Funktionsumfang wie C++, Java oder Python verwendet, sollte dann überhaupt noch Rekursion eingesetzt werden? Gibt es in solchen Sprachen eine praktische Verwendung für Rekursion oder ist sie nur noch eine Art Denksport?

6.7.1 Rekursion: Gegenargumente

Um die soeben gestellte Frage zu erforschen, listen wir zunächst einmal die negativen Eigenschaften der Rekursion auf.

Prinzipielle Komplexität Bei den meisten Problemstellungen ist es für den durchschnittlichen Programmierer schwieriger, die Aufgabe mittels Rekursion zu lösen. Selbst wenn Sie das Hauptkonzept der Rekursion vollkommen verstanden haben, ist es in den meisten Fällen einfacher, Code unter Verwendung von Schleifen zu schreiben.

Geschwindigkeit Funktionsaufrufe bringen einen erheblichen Verwaltungsaufwand mit sich. Rekursion macht ausgiebigen Gebrauch von Funktionsaufrufen und kann daher langsam sein.

Speicherplatzbedarf Rekursion verwendet nicht nur sehr viele Funktionsaufrufe, sie verschachtelt diese obendrein auch noch. Es kommt daher vor, dass eine lange Kette von Funktionsaufrufen auf das Ende anderer Funktionen wartet. Jeder einzelne gestartete Funktionsaufruf, der noch nicht abgeschlossen ist, belegt zusätzlichen Speicher auf dem Stack des Systems.

Auf den ersten Blick erscheint diese Liste von Eigenschaften als gewichtige Anklageschrift, die Rekursion als schwierig, langsam und speicherhungrig brandmarkt. Diese Argumente sind jedoch nicht allgemeingültig. Bei der Entscheidung zwischen Rekursion und Iteration lautet die einfachste Regel daher: Verwenden Sie Rekursion, wenn die genannten Argumente nicht greifen.

Betrachten wir noch einmal die Funktion zum Zählen der Blätter eines Binärbaums. Wie würden Sie die Aufgabe ohne Rekursion lösen? Es ist durchaus möglich, allerdings müssten Sie über einen Mechanismus verfügen, der die „Brotkrümelspur” der Knoten verwaltet, deren linker Kindknoten, anders als der rechte, schon aufgesucht wurde. Irgendwann würden diese Knoten erneut aufgesucht werden, damit auch der Weg über die rechte Seite eingeschlagen werden könnte. Sie könnten die Knoten in einer dynamischen Datenstruktur wie einem Stack speichern. Zum Vergleich sehen Sie hier eine Implementierung der Funktion, die den Stack der C++-STL verwendet:

int binaryTree::stackBasedCountLeaves() {
    if (_root == NULL) return 0;
    int leafCount = 0;
    stack< binaryTreeNode *> nodes;
    nodes.push(_root);
    while (!nodes.empty()) {
        treePtr currentNode =nodes.top();
        nodes.pop();
        if (currentNode->left == NULL && currentNode->right == NULL)
            leafCount++;
        else {
            if (currentNode->right != NULL) nodes.push(currentNode->right);
            if (currentNode->left != NULL)nodes.push(currentNode->left);
        }
     }
    return leafCount;
}

Der Code arbeitet nach dem gleichen Muster wie das Original, aber da Sie die Stack-Klasse möglicherweise vorher noch nie verwendet haben, sind einige Bemerkungen angebracht. Die Stack-Klasse funktioniert wie der in Kapitel 3 erläuterte Stack. Sie können Objekte nur oben auf dem Stack ablegen und Objekte nur entfernen, wenn diese sich oben befinden. Wir hätten das Zählen der Blätter mit jeder Datenstruktur durchführen können, die keine feste Größe besitzt, beispielsweise einem Vektor, aber die Verwendung des Stacks spiegelt den ursprünglichen Code am besten wider. Bei der Deklaration des Stacks geben wir den Datentyp an, den er speichern soll, in diesem Fall Zeiger auf die Datenstruktur binaryTreeNode . Wir verwenden hier vier verschiedene Methoden der Stack-Klasse. Die push-Methode legt ein Objekt (hier einen Zeiger auf einen Knoten) auf dem Stack ab. Die empty-Methode teilt uns mit, ob sich auf dem Stack noch Objekte befinden. Die top-Methode liefert eine Kopie des obersten Objekts auf dem Stack zurück und die pop-Methode schließlich entfernt das oberste Objekt auf dem Stack.

Der Code löst die Aufgabe, indem er einen Zeiger auf den ersten Knoten auf dem Stack ablegt und dann wiederholt Zeiger auf Knoten vom Stack entfernt und prüft, ob es sich um ein Blatt handelt, gegebenenfalls den Zähler erhöht und Zeiger auf Kindknoten, sofern vorhanden, auf dem Stack ablegt. Die Nachverfolgung der gefundenen, aber noch nicht verarbeiteten Knoten auf dem Stack geschieht also auf die gleiche Weise, wie die Kette rekursiver Aufrufe in der rekursiven Version die Knoten nachverfolgt, die erneut aufgesucht werden müssen. Beim Vergleich der iterativen mit der rekursiven Version fällt auf, dass keines der Standardargumente gegen Rekursion in diesem Fall sonderlich durchschlagend ist. Erstens ist der Code länger und komplizierter als in der rekursiven Version; das Argument der prinzipiellen Komplexität entfällt somit. Zählen Sie zweitens mal nach, wie viele Funktionsaufrufe stackBasedCountLeaves ausführt. Für das Aufsuchen eines internen Knotens (d. h. eines Knotens, der kein Blatt ist) werden bis zu fünf Funktionsaufrufe ausgeführt: jeweils einer von empty, top und pop sowie ein oder zwei von push. Die rekursive Version verwendet pro internem Knoten nur zwei Funktionsaufrufe. (Es wäre möglich, die Funktionsaufrufe der Stack-Instanz zu umgehen, wenn wir die Logik des Stacks in die Funktion integrieren, aber das würde die Funktion nur noch weiter verkomplizieren.) Und drittens verwendet die iterative Version zwar keinen zusätzlichen Speicherplatz auf dem Stack des Systems, macht aber Gebrauch von einem privaten Stack. Fairerweise muss gesagt werden, dass hierbei weniger Speicherplatz belegt wird, als durch die Verwaltung des Stacks bei rekursiven Aufrufen, aber es stellt dessen ungeachtet einen Verbrauch von Systemarbeitsspeicher dar, der mit der Größe des durchlaufenen Binärbaums wächst.

Da die Einwände gegen Rekursion im vorliegenden Fall kaum oder nur minimal zur Geltung kommen, ist eine rekursive Lösung für diese Aufgabenstellung eine gute Wahl. Allgemeiner ausgedrückt sollten Sie eine iterative Lösung wählen, sofern die Aufgabenstellung durch Iteration leicht zu bewältigen ist. Wenn die Iteration kompliziert wäre, sollten Sie Rekursion verwenden. Häufig ist dann ein Mechanismus wie die oben erwähnte „Brotkrümelspur” notwendig.

Das Durchlaufen von verzweigten Datenstrukturen, wie etwa Bäumen und Graphen, erfolgt grundsätzlich durch Rekursion. Die Verarbeitung linearer Datenstrukturen, wie Arrays und verkettete Listen, erfordert für gewöhnlich keine Rekursion, es gibt jedoch Ausnahmen. Es kann nie schaden, einen ersten Lösungsversuch mittels Iteration auszuprobieren und zu sehen, wohin das führt. Betrachten wir zum Abschluss noch zwei Aufgabenstellungen mit verketteten Listen.

Aufgabe: Ausgabe einer verketteten Liste

Schreiben Sie eine Funktion, die einen Startzeiger auf eine einfach verkettete Liste entgegennimmt, deren Datentyp eine Ganzzahl ist. Die Funktion gibt die Ganzzahlen zeilenweise in der Reihenfolge aus, in der diese in der Liste vorkommen.

Aufgabe: Ausgabe einer verketteten Liste in umgekehrter Reihenfolge

Schreiben Sie eine Funktion, die einen Startzeiger auf eine einfach verkettete Liste entgegennimmt, deren Datentyp eine Ganzzahl ist. Die Funktion gibt die Ganzzahlen zeilenweise in der umgekehrten Reihenfolge aus, in der diese in der Liste vorkommen.

Da die beiden Aufgaben sich spiegelbildlich zueinander verhalten, ist es naheliegend zu vermuten, dass auch deren Implementierungen Spiegelbilder voneinander sind. Das ist bei rekursiven Implementierungen auch tatsächlich der Fall. Unter Verwendung der früher eingeführten Datentypen listNode und listPtr können rekursive Funktionen zur Lösung beider Aufgaben wie folgt implementiert werden:

void displayListForwardsRecursion(listPtr head) {
    if (head != NULL) {
        cout << head->data << "\n";
        displayListForwardsRecursion(head->next);
    }
}
void displayListBackwardsRecursion(listPtr head) {
    if (head != NULL) {
        displayListBackwardsRecursion(head->next);
        cout << head->data << "\n";
    }
}

Wie Sie sehen, ist der Code beider Funktionen identisch – bis auf die Reihenfolge der Anweisungen innerhalb der if-Bedingungen. Dies macht jedoch den entscheidenden Unterschied aus. Im ersten Fall wird der Wert des ersten Knotens ausgegeben , bevor der rekursive Aufruf zur Anzeige der übrigen Liste erfolgt . Im zweiten Fall hingegen findet zunächst der Aufruf zur Anzeige der übrigen Liste statt, und erst danach wird der Wert des ersten Knotens ausgegeben . Die Ausgabe erfolgt dann in umgekehrter Reihenfolge.

Die Funktionen sind kurz und bündig und gleich lang, daher könnte man glauben, dass Rekursion hier zur Lösung beider Aufgabenstellungen am besten geeignet wäre, doch das täuscht. Um das zu erkennen, betrachten wir die iterativen Implementierungen der beiden Funktionen:

void displayListForwardsIterative(listPtr head) {
    for (listPtr current = head; current != NULL; current = current->next)
       cout << current->data << "\n";
}
void displayListBackwardsIterative(listPtr head) {
    stack<listPtr> nodes;
    for (listPtr current = head; current != NULL; current = current->next)
       nodes.push(current);
    while (!nodes.empty()) {
       nodePtr current = nodes.top();
       nodes.pop();
       cout << current->data << "\n";
    }
}

Die Funktion zur Ausgabe der Liste in normaler Reihenfolge durchläuft die Liste einfach nur in einer Schleife , wie wir sie aus Kapitel 4 kennen. Die Funktion zur Ausgabe in umgekehrter Reihenfolge ist allerdings um einiges komplizierter. Es handelt sich hier um dasselbe „Brotkrümelspur”-Problem, unter dem schon die Aufgaben mit Binärbäumen zu leiden hatten. Die Anzeige der Knoten einer verketteten Liste in umgekehrter Reihenfolge macht es per definitionem erforderlich, die vorher durchlaufenen Knoten erneut aufzusuchen. Bei einer einfach verketteten Liste ist das aber nicht möglich, ohne eine zweite Datenstruktur zu verwenden. In diesem Fall benötigen wir einen weiteren Stack. Nachdem dieser deklariert ist , legen wir mittels einer for-Schleife alle Knoten der Liste auf dem Stack ab . Da jedes Objekt auf dem Stack an oberster Stelle abgelegt wird, befindet sich das erste Objekt der verketteten Liste am unteren Ende des Stacks und das letzte am oberen Ende. Die folgende while-Schleife wird ausgeführt, bis der Stack wieder leer ist . In der Schleife wird der Stack allmählich abgeräumt, indem wir den Zeiger auf den aktuellen Knoten der Liste abrufen , diesen dann vom Stack entfernen und schließlich dessen Inhalt anzeigen . Auf dem Stack befindet sich jeweils der letzte Knoten der verketteten Liste, daher werden die Werte der Liste in umgekehrter Reihenfolge ausgegeben.

Es wäre durchaus möglich, wie bei der iterativen Version für den Binärbaum, ohne einen Stack auszukommen (indem innerhalb der Funktion eine zweite Liste in umgekehrter Reihe der ursprünglichen Liste erstellt wird). Es ist aber nicht möglich, die zweite Funktion so einfach zu gestalten wie die erste oder auf das Durchlaufen zweier Datenstrukturen (statt nur einer) zu verzichten. Beim Vergleich der Implementierungen ist gut erkennbar, dass die iterative Funktion zur Ausgabe in normaler Reihenfolge so simpel ist, dass eine rekursive Version praktisch keinen Vorteil bringt, es jedoch einige Nachteile gibt. Im Gegensatz dazu ist die rekursive Funktion zur Ausgabe in umgekehrter Reihenfolge erheblich einfacher als die iterative Version und dürfte ähnlich schnell sein. Bei der Ausgabe in umgekehrter Reihenfolge ist Rekursion also durchaus angebracht. Die Ausgabe in normaler Reihenfolge rekursiv zu programmieren, mag eine gute Übung sein, stellt aber keine vernünftige Verwendung der Rekursion dar.

6.8 Übungen

Die im Kapitel vorgestellten Ideen auszuprobieren, ist wie immer dringend geboten!

6.1

Schreiben Sie eine Funktion zur Berechnung der Summe nur der positiven Ganzzahlen eines Arrays. Lösen Sie die Aufgabe zunächst mittels Iteration. Konvertieren Sie dann die iterative Funktion mithilfe des vorgestellten Verfahrens in eine rekursive.

6.2

Ein Array enthält eine binäre Zeichenkette, der Wert jedes Elements ist also entweder 0 oder 1. Schreiben Sie eine bool-Funktion, die ermittelt, ob die Zeichenkette ungerade Parität (eine ungerade Anzahl von Einer-Bits) besitzt. Hinweis: Beachten Sie, dass die Funktion true (ungerade Parität) oder false (gerade Parität) zurückgeben soll, nicht die Anzahl der Einer-Bits. Lösen Sie die Aufgabe zunächst durch Iteration, dann mittels Rekursion.

6.3

Schreiben Sie eine Funktion, die ein Array von Ganzzahlen und einen Zielwert entgegennimmt und die Anzahl der Vorkommen des Zielwerts im Array zurückgibt. Lösen Sie die Aufgabe zunächst durch Iteration, dann mittels Rekursion.

6.4

Entwerfen Sie eine eigene Problemstellung. Suchen Sie nach einer bereits gelösten oder Ihnen trivial erscheinenden Aufgabe, die sich um die Verarbeitung eines eindimensionalen Arrays dreht, und lösen Sie diese (gegebenenfalls erneut) durch Rekursion.

6.5

Führen Sie Übung 6.1 statt mit einem Array mit einer verketteten Liste durch.

6.6

Führen Sie Übung 6.2 statt mit einem Array mit einer verketteten Liste durch.

6.7

Führen Sie Übung 6.3 statt mit einem Array mit einer verketteten Liste durch.

6.8

Entwerfen Sie eine eigene Problemstellung. Versuchen Sie, eine Aufgabe zur Verarbeitung einer verketteten Liste zu finden, die sich durch Iteration nur schwierig lösen lässt, und verwenden Sie zur Lösung Rekursion.

6.9

Manche Wörter besitzen hinsichtlich der Programmierung mehr als eine Bedeutung. In Kapitel 4 sind Sie dem Heap begegnet, auf dem man mittels new Speicherbereiche reservieren kann. Der Begriff Heap bezeichnet außerdem einen Binärbaum, bei dem der Wert eines Knotens größer ist als alle Werte in dessen linken oder rechten Unterbaum. Schreiben Sie eine rekursive Funktion, die prüft, ob es sich bei einem Binärbaum um einen Heap handelt.

6.10

Ein binärer Suchbaum ist ein Binärbaum, bei dem der Wert eines Knotens größer als alle Werte in dessen linken Unterbaum ist, aber kleiner als alle Werte in dessen rechten Unterbaum. Schreiben Sie eine rekursive Funktion, die prüft, ob es sich bei einem Binärbaum um einen binären Suchbaum handelt.

6.11

Schreiben Sie eine rekursive Funktion, die einen Startzeiger auf einen binären Suchbaum und einen Wert entgegennimmt. Die Funktion fügt einen neuen Knoten mit diesem Wert an der richtigen Position hinzu, sodass die Struktur eines binären Suchbaums erhalten bleibt. Hinweis: Der übergebene Startzeiger auf den binären Suchbaum sollte ein Referenzparameter sein.

6.12

Entwerfen Sie eine eigene Problemstellung. Überlegen Sie sich einige einfache statistische Auswertungen (Durchschnitt, Medianwert, Modalwert usw.), die auf eine Reihe numerischer Werte anwendbar sind. Versuchen Sie, rekursive Funktionen zur Berechnung dieser Auswertungen für einen Binärbaum mit Ganzzahlen zu schreiben. Warum ist die Programmierung von manchen Funktionen viel einfacher als die von anderen?

Kapitel 7: Wiederverwendung von Code

Dieses Kapitel unterscheidet sich sehr von den vorhergehenden, denn ich habe bislang immer betont, wie wichtig es ist, eigenständig Lösungen zu entwickeln. Darum geht es schließlich in diesem Buch: selbstständig Lösungen für Programmieraufgaben zu finden. Es war auch davon die Rede, dass man aus allem zuvor geschriebenen Code lernt und daher jeglichen Code sorgsam verwahren sollte, um künftig darauf zurückgreifen zu können. In diesem Kapitel gehen wir einen Schritt weiter und untersuchen, wie sich der Code und die Ideen anderer Programmierer zur Lösung unserer Aufgaben einsetzen lassen.

Die Aufnahme dieses Themas in das Buch mag Ihnen ein wenig seltsam vorkommen, wenn Sie an die ersten Kapitel zurückdenken. Ich habe darüber geschrieben, welch großer Fehler es sei, schwierige Probleme durch das Modifizieren des Codes anderer Leute lösen zu wollen. Dabei sind nicht nur die Erfolgsaussichten gering, denn selbst wenn der Code funktioniert, so wird man doch nichts daraus lernen. Wer sich nicht mehr ins Zeug legt, wird jedenfalls nie ein richtiger Programmierer und ist für die Softwareentwicklung nur von begrenztem Nutzen. Nachdem das geklärt ist: Sobald eine Programmieraufgabe eine gewisse Größe überschreitet, darf man vernünftigerweise nicht erwarten, dass ein Programmierer eine Lösung dafür von Grund auf neu entwickelt. Das wäre nicht nur ein unwirtschaftlicher Einsatz seiner Arbeitszeit, sondern würde auch voraussetzen, dass er in fast allem ein Experte ist. Darüber hinaus erhöht sich die Wahrscheinlichkeit, dass das Programm fehlerhaft oder schwer zu warten sein wird.

7.1 Sinnvolle und nicht sinnvolle Wiederverwendung von Code

Wir müssen also zwischen sinnvoller und nicht sinnvoller Wiederverwendung von Code unterscheiden. Eine sinnvolle Wiederverwendung ermöglicht es uns, bessere Programme zu schreiben, und das auch noch schneller. Die nicht sinnvolle Wiederverwendung erlaubt es vielleicht, sich ein Weilchen als Programmierer auszugeben, führt aber letztlich zu einer dürftigen Weiterentwicklung sowohl des Codes als auch des Programmierers. In Tabelle 7.1 sind die Unterschiede zwischen sinnvoller (links) und nicht sinnvoller (rechts) Wiederverwendung von Code aufgeführt. Wenn Sie erwägen, Code wiederzuverwenden, sollten Sie sich fragen, ob in Ihrem Fall eher die Eigenschaften in der linken oder in der rechten Spalte der Tabelle zutreffen.

Sinnvoll | Nicht sinnvoll
Einem Bauplan Folge leisten | Kopieren der Arbeit anderer
Vergrößert und erweitert Ihre Fähigkeiten | Täuscht Ihre Fähigkeiten nur vor
Hilft Ihnen beim Lernen | Hilft Ihnen, sich vor dem Lernen zu drücken
Spart kurz- und langfristig Zeit | Spart vielleicht kurzfristig Zeit, erhöht jedoch langfristig den Zeitaufwand
Führt zu einem funktionierenden Programm | Führt möglicherweise zu einem Programm, das überhaupt nicht funktioniert

Tabelle 7.1: Sinnvolle und nicht sinnvolle Wiederverwendung von Code

Hier muss betont werden, dass der Unterschied zwischen sinnvoller und nicht sinnvoller Wiederverwendung von Code nicht darin besteht, welchen Code oder wie Sie den Code wiederverwenden, sondern in Ihrer Beziehung zum entliehenen Code und den übernommenen Ideen. Beim Schreiben einer Hausarbeit für eine Vorlesung über Literatur bemerkte ich einmal, dass das in einer anderen Vorlesung Gelernte für meine Facharbeit von Bedeutung ist, also bezog ich es in meine Arbeit ein. Als ich einen Entwurf der Hausarbeit bei meiner Professorin einreichte, wies sie mich darauf hin, dass ich diese Informationen als Zitat kennzeichnen müsse. Frustriert fragte ich sie, wann ich denn mein eigenes Wissen in einer Veröffentlichung äußern darf, ohne eine Quelle dafür zu nennen. Sie antwortete mir, dass ich damit aufhören könne, bei eigenem Wissen auf andere zu verweisen, wenn ich solch ein Experte geworden bin, dass andere auf mich verweisen.

Beim Programmieren ist es eine sinnvolle Wiederverwendung, wenn Sie anhand der allgemeinen Beschreibung einer Idee eines anderen eigenständig Code schreiben oder wenn Sie Code verwenden, den Sie selbst hätten schreiben können. Sie werden in diesem Kapitel sehen, wie Sie Programmierideen so übernehmen können, dass deren Wiederverwendung Ihnen mit Sicherheit dabei hilft, zu einem besseren Programmierer zu werden – nicht zu einem bequemeren.

Ich möchte Ihre Aufmerksamkeit noch kurz auf die letzte Zeile in Tabelle 7.1 lenken. Häufig scheitert eine nicht sinnvolle Wiederverwendung von Code vollständig. Das ist wenig überraschend, denn es bedeutet ja, dass der Programmierer Code verwendet, den er eigentlich nicht versteht. Gelegentlich funktioniert der entliehene Code anfangs, aber wenn der Programmierer versucht, den zugrunde liegenden Code abzuändern oder zu erweitern, macht das fehlende, tiefgreifende Verständnis einen gezielten Lösungsansatz unmöglich. In seiner Not verfällt der Programmierer in blinden Aktionismus und probiert, durch Versuch und Irrtum voranzukommen, wodurch die wichtigste der allgemeinen Regeln bei der Lösung von Problemstellungen verletzt wird: Niemals ohne Plan.

7.2 Komponenten

Nachdem geklärt ist, welche Art der Wiederverwendung wir zum Ziel haben, wollen wir die verschiedenen Möglichkeiten kategorisieren. Ich verwende hier den Begriff Komponente für alles, was von einem Programmierer erstellt und von einem anderen beim Lösen einer Programmieraufgabe wiederverwendet werden kann. Komponenten können sehr abstrakt, aber auch ganz konkret sein, angefangen bei einer reinen Idee bis hin zu vollständig implementiertem Code. Wenn wir bezüglich des Lösens von Programmieraufgaben wieder die Analogie mit einem Heimwerker bemühen, dann entsprechen die erlernten Verfahren zur Lösung den Werkzeugen und die verschiedenen Komponenten Spezialbauteilen. Jede der nachstehend aufgeführten Komponenten stellt eine andere Möglichkeit dar, auf vorab geleistete Arbeiten anderer Programmierer zurückzugreifen.

7.2.1 Code-Blöcke

Ein Code-Block ist genau das: ein Textabschnitt mit Code, der vom Quelltext eines Programms in den Quelltext eines anderen Programms kopiert wird. Oder einfach ausgedrückt: Kopieren-und-Einsetzen. Dies ist die einfachste Form der Verwendung von Komponenten. Es handelt sich dabei oft um nicht sinnvolle Wiederverwendung mit allen ihren negativen Konsequenzen. Wenn Sie selbst geschriebenen Code kopieren, richten Sie damit natürlich keinen echten Schaden an. Sie sollten allerdings in Erwägung ziehen, den vorhandenen Code stattdessen in eine Klassenbibliothek oder ein ähnliches Gebilde auszulagern. Der Code lässt sich dann auf saubere und leichter handhabbare Art und Weise wiederverwenden.

7.2.2 Algorithmen

Ein Algorithmus ist ein Rezept zur Programmierung, eine spezielle Methode zum Erreichen eines Ziels. Er wird entweder in natürlicher Sprache formuliert oder als Ablaufdiagramm grafisch dargestellt. In Kapitel 3 haben wir beispielsweise die Sortierung von Arrays und verschiedene Methoden betrachtet, um die Sortierung durchzuführen. Eine dieser Methoden ist der Insertionsort-Algorithmus, und ich habe eine Beispielimplementierung davon vorgestellt. Beachten Sie, dass der Code nur eine Implementierung des Insertionsorts von vielen ist. Mit Insertionsort ist der Algorithmus an sich gemeint, die eigentliche Art und Weise der Sortierung eines Arrays, nicht speziell dieser Code. Beim Insertionsort wird wiederholt der jeweils nächste noch unsortierte Wert innerhalb des Arrays verschoben, bis er sich an der korrekten Position befindet. Jeglicher Code, der diese Methode zum Sortieren eines Arrays verwendet, ist ein Insertionsort.

Algorithmen stellen im Vergleich zu Code-Blöcken eine abstraktere Form der Wiederverwendung dar und führen im Allgemeinen zu vernünftigen Eigenschaften. Im Grunde sind Algorithmen nur Ideen, und Sie als Programmierer implementieren diese Ideen erst und setzen dabei Ihre Programmierkenntnisse und das tief gehende Verständnis des Algorithmus an sich ein. Die Algorithmen, die Sie üblicherweise verwenden werden, sind gut erforscht und ihre Leistungsfähigkeit in bestimmten Situationen ist vorhersagbar. Mit einem Algorithmus als Bauplan dürfen Sie darauf vertrauen, dass der Code korrekt und leistungsfähig arbeitet.

Es gibt jedoch auch einige potenzielle Nachteile, wenn man sich bei der Programmierung auf Algorithmen stützt. Wenn Sie einen Algorithmus verwenden, beginnen Sie auf der Ebene einer reinen Idee. Sie haben daher bis zum fertigen Code für den betroffenen Programmabschnitt einen weiten Weg vor sich. Ein Algorithmus spart zweifelsohne Zeit, denn der Aspekt der Problemlösung ist damit im Wesentlichen erledigt, aber je nach Algorithmus und dessen spezieller Anwendung in Ihrem Programm kann die Implementierung alles andere als trivial sein.

7.2.3 Entwurfsmuster

Im Zusammenhang mit der Programmierung bezeichnet ein Muster oder Entwurfsmuster eine Schablone für eine bestimmte Vorgehensweise bei der Programmierung. Das Konzept ist einem Algorithmus ähnlich, aber von diesem unterscheidbar. Ein Algorithmus ist ein Rezept zur Lösung eines speziellen Problems, Entwurfsmuster hingegen beschreiben allgemeine Vorgehensweisen in bestimmten bei der Programmierung auftretenden Situationen. Die Schwierigkeiten, die sich durch ein Entwurfsmuster umgehen lassen, sind typischerweise in der Struktur des Codes an sich begründet. In Kapitel 6 haben wir beispielsweise die Probleme erläutert, die eine rekursive Funktion in einer Klasse für verkettete Listen mit sich bringt: Die rekursive Funktion benötigt den Startzeiger auf den ersten Knoten der Liste als Parameter, diese Daten sollen aber privat bleiben. Die Lösung war hier die Verwendung einer Wrapper-Funktion, die eine Parameterliste an eine zweite anpasst. Diese Vorgehensweise stellt ein Entwurfsmuster dar. Wir können mit diesem Muster das Problem einer rekursiven Funktion in einer Klasse lösen, es lässt sich jedoch auch auf andere Weise anwenden. Nehmen wir an, eine Klasse linkedList für eine verkettete Liste ermöglicht es, an jeder Position in der Liste Objekte hinzuzufügen oder zu entfernen. Wir benötigen jedoch eine Stack-Klasse, also eine Liste, die das Hinzufügen und Entfernen von Objekten nur an einem Ende erlaubt. Wir könnten nun eine neue Klasse stack erstellen, die öffentliche Methoden für die typischen Stack-Operationen (wie push und pop) besitzt. Diese Methoden würden dann nur die Memberfunktionen der linkedList-Instanz aufrufen, die private Membervariable unserer stack-Klasse ist. Auf diese Weise können wir die Funktionalität der Klasse für verkettete Listen wiederverwenden und gleichzeitig die Schnittstelle einer Stack-Klasse bereitstellen.

Ebenso wie Algorithmen stellen Entwurfsmuster eine abstraktere Form der Verwendung von Komponenten dar, und die Beschäftigung damit ist eine ausgezeichnete Methode, Ihren Werkzeugkasten für die Programmierung zu bestücken. Entwurfsmuster haben mit Algorithmen jedoch auch einige der potenziellen Nachteile gemeinsam. Das Wissen um die Existenz eines Musters ist nicht gleichbedeutend mit der Fähigkeit, dieses Entwurfsmuster in der konkreten Programmiersprache zu implementieren, die Sie zur Programmierung einer Lösung ausgewählt haben. Außerdem ist es oft knifflig, Entwurfsmuster korrekt oder mit maximaler Leistungsfähigkeit zu implementieren. Es gibt beispielsweise ein Muster namens Singleton, bei dem es sich um eine Klasse handelt, die nur eine einzige Instanz ihrer selbst erlaubt. Das Anlegen eines Singletons ist unkompliziert, aber es kann überraschend schwierig sein, eine Singleton-Klasse zu erstellen, die nicht instanziiert wird, bevor man sie tatsächlich benötigt, wobei sich das dafür am besten geeignete Verfahren von Programmiersprache zu Programmiersprache unterscheiden kann.

7.2.4 Abstrakte Datentypen

In Kapitel 5 wurde ein abstrakter Datentyp vorgestellt, der durch die damit möglichen Operationen definiert ist, nicht durch die Art und Weise der Implementierung. Ein Stack von der Art, wie wir ihn schon mehrmals im Buch verwendet haben, ist hierfür ein gutes Beispiel. Abstrakte Datentypen ähneln Entwurfsmustern insofern, als sie die Auswirkungen von Operationen festlegen, aber nicht konkret definieren, wie diese zu implementieren sind. Dennoch gibt es, wie bei Algorithmen, wohlbekannte Implementierungsverfahren für diese Operationen. Ein Stack kann beispielsweise mit den verschiedensten Datenstrukturen realisiert werden, wie z. B. mit einem Array oder einer verketteten Liste. Sobald wir uns jedoch für eine bestimmte Datenstruktur entscheiden, sind damit in manchen Fällen auch schon gewisse Details der Implementierung festgelegt. Angenommen, wir implementieren einen Stack mittels einer verketteten Liste und haben nicht die Möglichkeit, eine Wrapper-Funktion für eine vorhandene Klasse zu verwenden, sondern müssen eigenen Code schreiben. Da ein Stack nach dem LIFO-Prinzip arbeitet, soll das Hinzufügen und Entfernen von Objekten nur an einem Ende der verketteten Liste gestattet sein. Darüber hinaus sollte es sich um den Anfang der Liste handeln. Theoretisch könnte man die Objekte zwar auch am Ende der Liste hinzufügen oder entfernen, dies würde jedoch bei jedem Hinzufügen oder Entfernen zu einem ineffizienten Durchlaufen der gesamten Liste führen. Um das zu vermeiden, wäre eine doppelt verkettete Liste nebst einem weiteren Zeiger auf das Ende der Liste erforderlich. Das Hinzufügen und Entfernen am Anfang der Liste ist viel einfacher und effizienter, daher sind auf verketteten Listen beruhende Stacks fast ausnahmslos auf dieselbe Weise implementiert.

Das Wort „abstrakt” im Begriff „abstrakter Datentyp” besagt zwar, dass es keine Vorschriften für die Implementierung des Datentyps gibt, in der Praxis werden Sie jedoch beim Implementieren eines abstrakten Datentyps nicht bei null anfangen, sondern sich an schon vorhandenen Implementierungen orientieren.

7.2.5 Bibliotheken

Eine Bibliothek ist eine Sammlung zusammengehöriger Codeabschnitte, die diesen Code typischerweise bereits in kompilierter Form enthält und auch die erforderlichen Deklarationen mitbringt. Bibliotheken können neben eigenständigen Funktionen oder Klassen auch alle anderen Codebestandteile enthalten. In C++ sind die Standardbibliotheken das bekannteste Beispiel. Die strcmp-Funktion, die wir in vorhergehenden Kapiteln verwendet haben, stammt aus der C-Bibliothek cstring und Containerklassen wie vector gehören zur C++-STL. Und auch NULL, das wir bei zeigerbasiertem Code immer wieder verwendet haben, gehört nicht zum C++-Sprachumfang, sondern ist in der Header-Datei stdlib.h definiert. Da so viele grundlegende Funktionalitäten in Bibliotheken enthalten sind, ist deren Verwendung bei der Programmierung heutzutage unvermeidlich.

Bei der Verwendung von Bibliotheken handelt es sich im Allgemeinen um sinnvolle Wiederverwendung von Code. Bibliotheken stellen die in einer Vielzahl von Programmen üblicherweise benötigten Funktionalitäten bereit und helfen dabei, zu vermeiden, dass Programmierer ständig „das Rad neu erfinden” müssen. Dennoch sollten angehende Programmierer bei der Verwendung von Bibliotheken danach streben, nicht nur eine Abkürzung zu nehmen, sondern aus den gemachten Erfahrungen zu lernen. Ein Beispiel hierfür folgt später in diesem Kapitel.

Viele Bibliotheken dienen allgemeinen Aufgaben, andere sind dafür ausgelegt, dem Programmierer in einer Hochsprache einfachen und stimmigen Zugriff auf die zugrunde liegende Plattform in Form von Programmierschnittstellen (APIs, Application Programming Interfaces) zu gewähren. Die Programmiersprache Java beispielsweise umfasst eine API namens JDBC (Java Database Connectivity), die Klassen bereitstellt, die es Programmen erlauben, auf standardisiertem Weg mit relationalen Datenbanken zu interagieren. Ein weiteres Beispiel ist DirectX, das Microsoft-Windows-Programmierern umfassende Sound- und Grafikfunktionalitäten zur Verfügung stellt. Die Bibliotheken bieten in beiden Fällen eine Verbindung zwischen der Hochsprache und der zugrunde liegenden Hard- und Software, nämlich zur Datenbankmaschinerie bzw. zur Sound- und Grafikhardware. Darüber hinaus ist die Wiederverwendung von Code hier nicht nur sinnvoll, sie ist für alle praktischen Zwecke sogar Bedingung. Bei der Datenbankprogrammierung in Java oder der Grafikprogrammierung unter Windows in C++ wird ein Programmierer diese APIs verwenden, und wenn nicht diese, dann andere APIs. Jedenfalls wird kein Programmierer eine neue Verbindungsmöglichkeit zur jeweiligen Plattform selbst aus dem Boden stampfen.

7.3 Kenntnisse über Komponenten erweitern

Bibliotheken sind so nützlich, dass Programmierer bei jeder sich bietenden Gelegenheit Gebrauch davon machen. Um bei der Lösung einer Aufgabe auf eine Komponente zurückgreifen zu können, muss man natürlich um deren Existenz wissen. Je nachdem, wie fein die Unterteilung vorgenommen wird, beläuft sich die Anzahl verfügbarer Komponenten auf einige Hunderte oder sogar Tausende, und der angehende Programmierer kommt nur mit einigen wenigen in Berührung. Ein guter Programmierer sollte daher stets danach streben, seine Kenntnisse über Komponenten zu erweitern. Die Anhäufung dieses Wissens geschieht auf zweierlei Weise: Ein Programmierer kann einen Teil seiner Zeit ganz allgemein dem Erlernen neuer Komponenten widmen oder aber gezielt nach einer Komponente suchen, um eine konkrete Aufgabe zu lösen. Wir wollen Ersteres als forschendes Lernen und Letzteres als Lernen bei Bedarf bezeichnen. Um sich als Programmierer weiterzuentwickeln, müssen Sie beide Ansätze verfolgen. Wenn Ihnen die Syntax Ihrer bevorzugten Programmiersprache erst einmal in Fleisch und Blut übergegangen ist, stellt das Auffinden neuer Komponenten eine der wichtigsten Methoden zur Vertiefung der Programmierkenntnisse dar.

7.3.1 Forschendes Lernen

Wenden wir uns einem Beispiel für forschendes Lernen zu. Angenommen, wir möchten mehr über Entwurfsmuster erfahren. Glücklicherweise herrscht allgemeiner Konsens darüber, welche Entwurfsmuster am nützlichsten sind und am häufigsten verwendet werden. Wir können daher nahezu beliebige Ressourcen zu diesem Thema anzapfen und können dabei ziemlich sicher sein, dass uns nichts von Wichtigkeit entgeht. Wir würden bereits von der Lektüre einer Liste bekannter Entwurfsmuster profitieren und noch mehr Einsichten gewinnen, wenn wir einige davon implementieren.

In einer typischen Liste von Entwurfsmustern wird sich das Muster Strategie finden, das auch als Policy bezeichnet wird. Der Grundgedanke hierbei ist es, erst zur Laufzeit einen Algorithmus (oder einen Teil davon) auszuwählen. In reinster Form ermöglicht es dieses Muster, die Arbeitsweise einer Funktion oder einer Methode zu ändern, ohne deren Ergebnis zu beeinflussen. Eine Klassenmethode, die Daten sortiert, könnte es beispielsweise erlauben, den Sortieralgorithmus (wie Quicksort oder Insertionsort) auszuwählen. Das Resultat ist in jedem Fall dasselbe, nämlich sortierte Daten, aber es dem Client zu gestatten, den Sortieralgorithmus auszuwählen, könnte Geschwindigkeitsvorteile bringen. Der Client könnte z. B. bei Daten, die viele Duplikate enthalten, auf Quicksort verzichten. Bei der schwächeren Form dieses Musters (Policy) beeinflusst die Auswahl des Clients das Ergebnis. Denken Sie beispielsweise an eine Klasse, die ein Spielkartenblatt repräsentiert. Die Sortiermethode könnte hier festlegen, ob ein Ass einen hohen Wert (über dem König) oder einen niedrigen (unterhalb der Zwei) besitzt.

Lernen in der Praxis

Nach der Lektüre des letzten Absatzes wissen Sie zwar, was das Entwurfsmuster Strategie bedeutet, aber Sie haben es sich noch nicht richtig zu eigen gemacht. Wenn Sie im Baumarkt in einer Kiste mit verschiedenen Werkzeugen herumstöbern, ist das nicht dasselbe, wie eines davon zu kaufen und zu benutzen. Es ist hier der gleiche Unterschied. Betrachten wir das Entwurfsmuster also näher und wenden wir es an. Am schnellsten geht das, wenn wir es in vorhandenen Code einarbeiten. Denken wir uns also eine Aufgabenstellung aus, die sich unter Verwendung dieses Entwurfsmusters lösen lässt und auf Code beruht, den wir schon geschrieben haben.

Aufgabe: Klassenvorsteher

Für jede Klasse einer Schule wird ein Schüler als Klassenvorsteher ernannt, der dafür zuständig ist, Aufsicht zu führen, wenn der Lehrer den Raum verlassen muss. Ursprünglich wurde dieser Posten dem Schüler mit der besten Gesamtnote verliehen, aber nun sind einige Lehrer der Ansicht, der älteste Schüler sollte Klassenvorsteher sein, also derjenige mit der niedrigsten Schüler-ID, da diese nach sinkendem Alter vergeben werden. Eine weitere Lehrerfraktion findet das ganze Verfahren albern und will dagegen protestieren, indem der Schüler, dessen Namen in der alphabetisch sortierten Schülerliste als Erstes vorkommt, zum Klassenvorsteher ernannt wird. Die Aufgabe besteht nun darin, der studentCollection-Klasse eine Methode zur Auswahl des Klassenvorstehers hinzuzufügen, die den Kriterien der verschiedenen Lehrergruppen gerecht wird.

Wie Sie sehen, findet bei dieser Aufgabe die Policy-Form des Entwurfsmusters Anwendung. Die Methode soll, je nach gewähltem Kriterium, verschiedene Schüler zurückliefern. Um das in C++ zu erreichen, werden wir Funktionszeiger (bzw. Methodenzeiger) verwenden. In Kapitel 3 haben wir dieses Thema schon kurz gestreift, als die qsort-Funktion vorgestellt wurde, die einen Zeiger auf die Funktion entgegennimmt, anhand derer zwei Objekte des Arrays beim Sortieren verglichen werden. Wir machen hier etwas Ähnliches: Wir verwenden eine Reihe von Vergleichsfunktionen, die jeweils zwei studentRecord-Instanzen entgegennehmen und anhand Note, Schüler-ID oder Name feststellen, welcher der beiden Schüler besser als Klassenvorsteher geeignet ist.

Zunächst müssen wir einen Typ für unsere Vergleichsfunktionen festlegen:

typedef bool(* firstStudentPolicy)(studentRecord r1, studentRecord r2);

Die Deklaration definiert einen Datentyp namens firstStudentPolicy als Zeiger auf eine Funktion, die einen Wahrheitswert zurückgibt und zwei Parameter des Typs studentRecord entgegennimmt. Die Klammern um *firstStudentPolicy sind notwendig, um zu verhindern, dass die Deklaration als Funktion interpretiert wird, die einen Zeiger auf einen bool-Wert zurückliefert. Nach der Deklaration folgen die drei Vergleichsfunktionen:

bool higherGrade(studentRecord r1, studentRecord r2) {
   return r1.grade() > r2.grade();
}
bool lowerStudentNumber(studentRecord r1, studentRecord r2) {
   return r1.studentID() < r2.studentID();
}
bool nameComesFirst(studentRecord r1, studentRecord r2) {
   return strcmp(r1.name().c_str(), r2.name().c_str())< 0;
}

Die ersten beiden Funktionen sind besonders einfach: higherGrade liefert true zurück, wenn der erste Schülerdatensatz die bessere Note besitzt, und lowerStudentNumber gibt true zurück, wenn der erste Datensatz die niedrigere Schüler-ID aufweist. Die dritte Funktion nameComesFirst erledigt im Prinzip die gleiche Aufgabe, benötigt aber die Bibliotheksfunktion strcmp , die zwei C-Strings (ein char-Array, das mit NULL endet, statt einer string-Instanz) als Parameter erwartet. Wir müssen daher jeweils die c_str-Methode mit den name-Strings der Schülerdatensätze aufrufen . Die strcmp-Funktion gibt einen negativen Wert zurück, wenn der erste String bei alphabetischer Sortierung vor dem zweiten erscheint, daher prüfen wir, ob der Rückgabewert kleiner als 0 ist . Nun können wir auch die studentCollection-Klasse ändern:

class studentCollection {
private:
   struct studentNode {
      studentRecord studentData;
      studentNode * next;
};
public:
   studentCollection();
   ˜studentCollection();
   studentCollection(const studentCollection &copy);
   studentCollection& operator=(const studentCollection &rhs);
   void addRecord(studentRecord newStudent);
   studentRecord recordWithNumber(int IDnum);
   void removeRecord(int IDnum);
   void setFirstStudentPolicy(firstStudentPolicy f);
   studentRecord firstStudent();
private:
   firstStudentPolicy _currentPolicy;
   typedef studentNode * studentList;
   studentList _listHead;
   void deleteList(studentList &listPtr);
   studentList copiedList(const studentList copy);
};

Dies entspricht der Klassendeklaration aus Kapitel 5 mit drei neuen Elementen: der privaten Membervariablen _currentPolicy , die einen Zeiger auf eine unserer Vergleichsfunktionen speichert, der Methode setFirstStudentPolicy zum Ändern der Vergleichsfunktion und der firstStudent-Methode selbst , die entsprechend der aktuellen Vergleichsfunktion einen Schülerdatensatz zurückliefert. Der Code für setFirstStudentPolicy ist einfach:

void studentCollection::setFirstStudentPolicy(firstStudentPolicy f) {
   _currentPolicy = f;
}

Wir müssen außerdem den Standardkonstruktor anpassen, um das aktuelle Vergleichskriterium zu initialisieren:

studentCollection::studentCollection() {
   _listHead = NULL;
   _currentPolicy = NULL;
}

Jetzt können wir firstStudent schreiben:

studentRecord studentCollection::firstStudent() {
   if (_listHead == NULL || _currentPolicy == NULL) {
      studentRecord dummyRecord(-1, -1, "");
      return dummyRecord;
   }
   studentNode * loopPtr = _listHead;
   studentRecord first = loopPtr->studentData;
   loopPtr = loopPtr->next;
   while (loopPtr != NULL) {
      if (_currentPolicy(loopPtr->studentData, first)) {
         first = loopPtr->studentData;
      }
      loopPtr = loopPtr->next;
   }
   return first;
}

Die Methode überprüft zunächst die Sonderfälle. Falls es keine Liste oder kein Vergleichskriterium gibt, liefern wir einen Platzhalterdatensatz zurück. Anderenfalls durchlaufen wir die Liste mittels des üblichen Suchverfahrens, um den Schüler zu finden, der dem aktuellen Kriterium zufolge am besten geeignet ist. Wir weisen den ersten Datensatz an first zu , lassen die Schleifenvariable auf den zweiten Datensatz verweisen und starten die Schleife. Innerhalb der Schleife entscheiden wir anhand eines Aufrufs der Vergleichsfunktion , ob der aktuelle Schüler dem ausgewählten Vergleichskriterium nach besser geeignet ist als der bislang beste. Wenn die Schleife beendet ist, wird der ausgewählte Schülerdatensatz zurückgegeben .

Analyse dieser Lösung

Nachdem wir eine Aufgabenstellung mittels des Entwurfsmusters „Strategie/Policy” gelöst haben, werden wir künftige Situationen, in denen dieses Verfahren anwendbar ist, leichter erkennen können, als wenn wir nur einmalig davon gelesen hätten, ohne es je anzuwenden. Außerdem haben wir nun die Möglichkeit, uns eine eigene Meinung darüber zu bilden, welchen Wert das Verfahren besitzt, wann es sinnvollerweise anwendbar ist und wann es ein Fehler wäre, es einzusetzen (oder es zumindest mehr Aufwand als Nutzen bringt). Vielleicht ist Ihnen bei diesem Entwurfsmuster der Gedanke gekommen, dass es Verkapselung und Information Hiding schwächt. Wenn beispielsweise der Client die Vergleichsfunktionen bereitstellt, benötigt er Zugriff auf Datentypen, die normalerweise klassenintern wären, wie in diesem Fall der studentRecord-Typ. (In den Übungen wird erörtert, wie sich dieses Problem umgehen lässt.) Der Client-Code könnte dann nicht mehr funktionieren, sollten wir jemals etwas an diesem Datentyp ändern. Solche Bedenken müssen gegen die Vorteile des Musters abgewogen werden, bevor man es auf weitere Projekte anwendet. In den vorhergehenden Kapiteln wurde erläutert, dass es genauso wichtig ist, zu erkennen, wann ein Verfahren eingesetzt werden sollte (oder wann nicht), wie das Verfahren an sich einzusetzen. Sie gewinnen Einblick in diese wichtige Frage, wenn Sie den eigenen Code gründlich begutachten.

Als weitere Übung empfehle ich, dass Sie Ihre abgeschlossenen Projekte durchforsten und nach Code suchen, bei dem ein Refactoring mittels dieses Verfahrens möglich wäre. Da im „wahren Leben” mit der Programmierung immer auch das Ergänzen und Ändern vorhandenen Codes verbunden ist, stellt dies ein ausgezeichnetes Training für solche Modifizierungen dar und wird Ihre Fähigkeiten bezüglich der jeweiligen Komponente deutlich fortentwickeln. Außerdem ist einer der Vorteile sinnvoller Wiederverwendung von Code, dass man dabei etwas lernt, und diese Übung maximiert den Lerneffekt.

7.3.2 Lernen bei Bedarf

Man könnte das im letzten Abschnitt Beschriebene als „Lernen durch Umherstreifen” bezeichnen. Solche gedanklichen Ausflüge sind für Programmierer durchaus nützlich, aber manchmal muss man sich auch möglichst unmittelbar auf ein konkretes Ziel zubewegen. Wenn Sie an einer bestimmten Aufgabenstellung arbeiten und vermuten, dass eine gewisse Komponente von großer Hilfe sein könnte, werden Sie kaum, insbesondere nicht unter Termindruck, wahllos in der Welt der Programmierung umherirren wollen und hoffen, dabei zufällig auf das Benötigte zu stoßen. Stattdessen möchten Sie die entsprechende Komponente schnellstmöglich auftreiben. Das hört sich allerdings knifflig an, denn wie soll man etwas finden, wenn man gar nicht genau weiß, was man eigentlich sucht? Betrachten Sie folgendes Beispiel:

Aufgabe: Effizientes Durchlaufen einer Liste

Ein neues Projekt soll unsere studentCollection-Klasse verwenden. Der Client-Code muss alle Schülerdatensätze durchlaufen können. Wegen des Information Hidings darf dem Client-Code kein direkter Zugriff auf die Liste gewährt werden, das Durchlaufen der Liste soll aber dennoch möglichst effizient vonstattengehen.

Das entscheidende Wort in dieser Aufgabenstellung lautet effizient, daher muss geklärt werden, was genau das in diesem Fall bedeutet. Nehmen wir an, die studentCollection-Instanz besteht aus 100 Schülerdatensätzen. Wäre direkter Zugriff auf die verkettete Liste möglich, könnten wir eine Schleife programmieren, die zum Durchlaufen der Liste 100 Mal ausgeführt wird. Noch effizienter ist das Durchlaufen der Liste nicht möglich. Jede Lösung, die mehr als 100 Schleifendurchläufe erfordert, wäre ineffizient.

Gäbe es diese Randbedingung nicht, könnten wir versuchen, die Aufgabe zu lösen, indem wir unserer Klasse eine einfache recordAt-Methode hinzufügen, die den Schülerdatensatz an einer bestimmten Position zurückliefert, wobei die Nummerierung der Datensätze mit 1 beginnt:

studentRecord studentCollection::recordAt(int position) {
   studentNode * loopPtr = _listHead;
   int i = 1;
   while (loopPtr != NULL && i < position) {
      i++;
      loopPtr = loopPtr->next;
   }
   if (loopPtr == NULL) {
      studentRecord dummyRecord(-1, -1, "");
      return dummyRecord;
   } else {
      return loopPtr->studentData;
   }
}

Wir verwenden in dieser Methode eine Schleife zum Durchlaufen der Liste, die abbricht, wenn der gesuchte Datensatz gefunden oder das Ende der Liste erreicht ist. Am Ende der Schleife erstellen wir einen Platzhalterdatensatz und geben diesen zurück , sofern das Ende der Liste erreicht wurde. Anderenfalls wird der Datensatz an der angegebenen Position zurückgeliefert . Problematisch ist hierbei, dass wir die Liste durchlaufen, um nur einen einzelnen Schülerdatensatz zu finden. Die Liste wird zwar nicht unbedingt komplett durchlaufen, denn die Schleife bricht ja ab, wenn die gesuchte Position erreicht ist, aber dennoch handelt es sich um ein Durchlaufen der Liste. Nehmen wir an, der Client-Code versucht, einen Durchschnittswert der Schülernoten zu errechnen:

int gradeTotal = 0;
for (int recNum = 1; recNum <= numRecords; recNum++) {
   studentRecord temp = sc.recordAt(recNum);
   gradeTotal += temp.grade();
}
double average = (double) gradeTotal / numRecords;

Setzen Sie bei diesem Codeabschnitt voraus, dass sc eine deklarierte und initialisierte studentCollection-Instanz und numRecords eine int-Variable zum Speichern der Anzahl der Datensätze ist. Nehmen wir an, numRecords ist 100. Auf den ersten Blick könnte man meinen, dass die Berechnung des Durchschnittwerts dann nur 100 Durchläufe der for-Schleife erfordert, aber da jeder Aufruf von recordAt seinerseits einen partiellen Schleifendurchlauf nach sich zieht, werden auch 100 Listendurchläufe ausgeführt, von denen jeder durchschnittlich etwa 50 Mal die while-Schleife in der recordAt-Methode durchläuft. Statt der effizienten 100 Schritte sind hier rund 5.000 Schritte erforderlich, was sehr ineffizient ist.

Auf Komponentensuche

Nun sind wir beim eigentlichen Problem angekommen. Dem Client Zugriff auf Containerklassen zu gewähren, um diese zu durchlaufen, ist einfach. Diesen Zugriff effizient zu ermöglichen, ist es nicht. Natürlich könnten wir dieses Problem nun selbst in Angriff nehmen, aber wir würden sehr viel schneller zu einer Lösung kommen, wenn wir uns einer geeigneten Komponente bedienen könnten. Zunächst einmal müssen wir annehmen, dass es eine solche Komponente, die uns bei der Lösung hilft, überhaupt gibt. Um einen möglichst großen Nutzen aus Komponenten zu ziehen, muss man nach Situationen Ausschau halten, in denen diese besonders nützlich sind. Wenn Sie bei einem bestimmten Aspekt einer Aufgabe nicht weiterkommen, sollten Sie Folgendes probieren:

Formulieren Sie das Problem möglichst allgemein neu.

Stellen Sie sich die Frage: „Handelt es sich hier wohl um ein weitverbreitetes Problem?”

Wenn wir unser Problem als „Dem Client-Code ermöglichen, die Durchschnittsnote einer verketteten Liste, die in einer Klasse verkapselt ist, möglichst effizient zu berechnen” formulieren, hört es sich nach einer sehr speziellen Situation an, daher ist der erste Punkt von Bedeutung. Formuliert man hingegen „Dem Client-Code ermöglichen, eine verkettete Liste möglichst effizient zu durchlaufen, ohne ihm direkten Zugriff auf die Zeiger der Liste zu gewähren”, beginnt man zu begreifen, dass es sich durchaus um ein weitverbreitetes Problem handeln könnte. Wir könnten uns daher die Frage stellen: „Es gibt so viele Programme, die verkettete Listen und andere sequenzielle Datenstrukturen in Klassen zum Speichern von Daten verwenden, dass doch irgendwelche anderen Programmierer einen Weg gefunden haben müssen, effizienten Zugriff auf die Objekte zu ermöglichen?”

Aufspüren einer geeigneten Komponente

Wir sind also entschlossen, nach einer geeigneten Komponente zu suchen. Der Deutlichkeit halber formulieren wir die ursprüngliche Problemstellung als Forschungsaufgabe: „Finden Sie eine Komponente, die dazu geeignet ist, unsere studentCollection-Klasse so abzuändern, dass Client-Code interne Listen möglichst effizient durchlaufen kann.” Wie nehmen wir das Problem in Angriff? Wir könnten die vier Typen von Komponenten näher untersuchen: Entwurfsmuster, Algorithmen, abstrakte Datentypen oder Bibliotheken.

Nehmen wir zunächst die C++-Standardbibliotheken unter die Lupe. Dabei müssen wir gar nicht unbedingt nach einer Klasse Ausschau halten, um diese direkt in unsere Lösung einzubauen, sondern wir suchen stattdessen nach einer Klassenbibliothek mit ähnlicher Funktionalität wie unsere studentCollection-Klasse, um auf Ideen zu kommen. Dazu verwenden wir, wie beim Lösen von Problemstellungen üblich, die Suche nach Analogien. Wenn wir eine Klasse finden können, die mit vergleichbaren Schwierigkeiten zu kämpfen hat, können wir vielleicht deren Lösung übernehmen. Sie haben bereits Containerklassen der C++-Bibliothek kennengelernt, wie z. B. vector, und sollten sich nach einer Containerklasse umsehen, die unserer studentCollection-Klasse möglichst ähnlich ist. Beim Nachschlagen der Containerklassen im bevorzugten C++-Handbuch oder im Internet trifft man schnell auf eine Klasse namens list, auf die unsere Beschreibung passt. Unterstützt die Klasse das effiziente Durchlaufen der Liste per Client-Code? Das tut sie tatsächlich, und zwar unter Verwendung eines Objekts, das als Iterator bezeichnet wird. Die list-Klasse stellt die Methoden begin und end bereit, die Iteratoren zurückliefern, also Objekte, die auf ein bestimmtes Element der Liste verweisen und schrittweise erhöht werden können, damit sie auf das nachfolgende Element der Liste zeigen. Mit einer list<int> namens intList, die Ganzzahlen enthält, und einem list<int>::iterator namens iter, lassen sich sämtliche Ganzzahlen der Liste durch folgenden Code anzeigen:

iter = intList.begin();
while (iter != intList.end()) {
   cout << *iter << "\n";
iter++; }

Die list-Klasse löst das Problem, dem Client einen effizienten Mechanismus zum Durchlaufen der Liste bereitzustellen, durch Iteratoren. Man könnte an dieser Stelle auf den Gedanken kommen, den Code der list-Klasse unserer studentCollection-Klasse hinzuzufügen und unsere selbst programmierte verkettete Liste dadurch zu ersetzen. Wir müssten dann begin- und end-Methoden als Wrapper für die gleichnamigen Methoden des eingebetteten list-Objekts implementieren und die Aufgabe wäre erledigt. Dies steht allerdings in krassem Widerspruch zur sinnvollen Wiederverwendung von Code. Sobald Sie Iteratoren vollständig verstanden haben und diese in eigenem Code nachbilden können, ist der Einsatz einer vorhandenen Klasse aus der C++-STL im eigenen Code eine vernünftige Alternative, vielleicht sogar die beste. Solange Sie dazu aber noch nicht in der Lage sind, stellt die list-Klasse nur eine Abkürzung dar, die der Fortentwicklung als Programmierer nicht dienlich ist. Manchmal kommt man natürlich nicht umhin, von Komponenten Gebrauch zu machen, die sich nicht nachbilden lassen. Aber wenn man sich angewöhnt, beim Lösen einer Aufgabe von anderen Programmierern abhängig zu sein, läuft man Gefahr, selbst nie zu einem richtigen Problemlöser zu werden.

Lassen Sie uns also die Iteratoren selbst implementieren! Vorher wollen wir uns jedoch kurz ansehen, auf welchen anderen Wegen wir das Ziel hätten erreichen können. Wir haben uns zunächst in der C++-STL auf die Suche gemacht, hätten aber ebenso gut an anderer Stelle anfangen können. Wir hätten beispielsweise eine Liste gängiger Entwurfsmuster durchforsten können. Unter der Überschrift „Verhaltensmuster” findet sich das Iterator-Muster, das dem Client sequenziellen Zugriff auf eine Objektsammlung gestattet, ohne die Struktur der Sammlung zu enthüllen. Das ist genau das, was wir brauchen, jedoch hätten wir dieses Entwurfsmuster nur bei gezielter Suche gefunden oder uns aufgrund früherer Recherchen an dessen Existenz erinnern müssen. Wir hätten bei der Suche auch mit abstrakten Datentypen beginnen können, denn Listen im Allgemeinen und verkettete Listen im Besonderen sind gebräuchliche abstrakte Datentypen. Allerdings wird das Durchlaufen einer Liste durch einen Client in vielen Beschreibungen und Implementierungen des abstrakten Datentyps „Liste” gar nicht als elementare Operation angesehen und Iteratoren finden überhaupt keine Erwähnung. Hätten wir die Suche im Bereich Algorithmen begonnen, wäre diese wahrscheinlich erfolglos geblieben. Algorithmen beschreiben oft überaus trickreichen Code, und der Code zum Erstellen eines Iterators ist, wie Sie bald sehen werden, einigermaßen simpel. Im vorliegenden Fall ist daher die Klassenbibliothek der schnellste Weg, um ans Ziel zu gelangen, gefolgt von den Entwurfsmustern. Im Allgemeinen müssen Sie jedoch alle vier Typen bei der Suche nach hilfreichen Komponenten berücksichtigen.

Verwendung der Komponente

Nun steht fest, dass wir für unsere studentCollection-Klasse einen Iterator implementieren möchten, aber der list-Klasse der Standardbibliothek ist nur deren externes Verhalten anzusehen. Wenn wir bei der Implementierung nicht weiterkommen, könnte man es in Erwägung ziehen, den Quellcode der list-Klasse und der übergeordneten Klassen in Augenschein zu nehmen, aber angesichts der Schwierigkeiten, größere Mengen unvertrauten Codes zu verstehen, kann dies nur der letzte Ausweg sein. Stattdessen wollen wir die Aufgabe lieber selbst durchdenken. Das vorhergehende Codebeispiel dient dabei als Orientierungshilfe und wir können feststellen, dass ein Iterator durch die folgenden vier Operationen definiert ist:

Die Containerklasse besitzt eine Methode, die einen Iterator bereitstellt, der auf das erste Element der Sammlung verweist. In der list-Klasse heißt diese Methode begin.

Es lässt sich überprüfen, ob das Ende der Sammlung erreicht worden ist. Beim Beispiel der list-Klasse liefert die Methode end einen speziellen Iterator, mit dem sich dies testen lässt.

Es gibt eine Methode in der Iteratorklasse, um zum nächsten Element der Datensammlung zu springen. Im vorhergehenden Beispiel ist dies der überladene ++-Operator.

Eine weitere Methode liefert das aktuell ausgewählte Element der Datensammlung zurück. Im Beispiel erledigt dies der (vorangestellte) Operator „*”.

Das Schreiben dieses Codes erscheint nicht sonderlich schwer; es muss eigentlich nur alles an Ort und Stelle arrangiert werden, fangen wir also an! Der obigen Beschreibung nach muss unser Iterator, den wir scIterator taufen, einen Verweis auf ein Element in studentCollection besitzen und zum jeweils nächsten Element der Datensammlung springen können. Wir benötigen also einen Zeiger studentNode, der auf den studentRecord-Datensatz verweist und auch auf den nächsten studentNode zeigen kann. Der private Abschnitt der Iteratorklasse besitzt also folgende Membervariable:

studentCollection::studentNode * current;

Und schon haben wir ein Problem. Der Datentyp studentNode wird im privaten Abschnitt der studentCollection-Klasse deklariert, daher wird oben stehende Codezeile nicht funktionieren. Der erste kommende Gedanke, dass studentNode möglicherweise nicht privat deklariert werden sollte, ist jedoch nicht richtig. Dieser Datentyp ist naturgemäß privat, denn wir wollen verhindern, dass irgendwelcher Client-Code von einem Detail der Implementierung abhängig ist und nicht mehr funktioniert, wenn wir an der Klasse später Änderungen vornehmen. Dennoch müssen wir scIterator Zugriff auf den privaten Datentyp gewähren. Wir verwenden zu diesem Zweck eine friend-Deklaration und fügen im öffentlichen Abschnitt von studentCollection folgende Zeile hinzu:

friend class scIterator;

Nun darf scIterator auf die privaten Deklarationen innerhalb der studentCollection-Klasse zugreifen und ist auch über studentNode informiert. Wir deklarieren außerdem noch zwei Konstruktoren:

scIterator::scIterator() {
   current = NULL;
}
scIterator::scIterator(studentCollection::studentNode * initial) {
   current = initial;
}

Wenden wir uns kurz der studentCollection-Klasse zu und schreiben die begin-Methode, die einen Iterator zurückliefert, der auf das erste Objekt in der Datensammlung verweist. Gemäß der Namenskonvention, die ich im Buch verwende, sollte die Bezeichnung der Methode ein Substantiv sein, wie z. B. firstItemIterator:

scIterator studentCollection::firstItemIterator() {
   return scIterator(_listHead);
}

Wie Sie sehen, müssen wir hier nur den Startzeiger der verketteten Liste in eine scIterator-Instanz „verpacken” und diesen Zeiger zurückliefern. Wenn es Ihnen ähnlich ergeht wie mir, machen diese hier „herumfliegenden” Zeiger Sie vielleicht ein wenig nervös, aber beachten Sie, dass scIterator lediglich einen Verweis auf ein Element der studentCollection-Liste enthält, selbst aber keinen Speicherplatz reserviert. Um tiefe Kopien und überladene Zuweisungsoperatoren müssen wir uns daher keine Gedanken machen.

Zurück zur scIterator-Klasse und deren anderen Methoden. Wir benötigen eine Methode, die dafür sorgt, dass der Iterator auf das nächste Element zeigt und eine weitere, um zu prüfen, ob das Ende der Datensammlung erreicht ist. Wir sollten beide Methoden gleichzeitig betrachten. Wir müssen in Erfahrung bringen, welchen Wert der Iterator besitzen sollte, wenn er den letzten Knoten der Liste übersprungen hat. Wenn wir nichts weiter unternehmen, erhält er den Wert NULL, was die einfachste Lösung wäre. Beachten Sie hier, dass wir den Iterator im Standardkonstruktor mit NULL initialisieren. Wenn wir NULL zum Markieren des Listenendes verwenden, lassen sich diese beiden Fälle daher nicht mehr unterscheiden, aber das spielt an dieser Stelle keine Rolle. Der Code sieht folgendermaßen aus:

void scIterator::advance() {
   if (current != NULL)
      current = current->next;
   }
bool scIterator::pastEnd() {
   return current == NULL;
}

Wir verwenden den Iterator hier nur, um das ursprüngliche Problem zu lösen, und werden nicht versuchen, den Iterator der C++-STL exakt nachzubilden. Wir müssen daher auch nicht dieselbe Schnittstelle verwenden. In diesem Fall verwende ich statt des überladenen ++-Operators eine Methode namens advance , die prüft, ob der Zeiger current von NULL verschieden ist , bevor sie zum nächsten Knoten springt . Außerdem ist es mir zu mühsam, einen eigenen end-Iterator zu programmieren und ich verwende einfach eine bool-Methode namens pastEnd , die abfragt, ob es noch weitere Knoten gibt.

Zu guter Letzt benötigen wir noch eine Möglichkeit, die aktuelle studentRecord-Instanz abzufragen:

studentRecord scIterator::student() {
   if (current == NULL) {
      studentRecord dummyRecord(-1, -1, "");
      return dummyRecord;
   } else {
      return current->studentData;
   }
}

Der Sicherheit halber erstellen wir einen Platzhalterdatensatz und geben diesen zurück, wenn der Zeiger den Wert NULL besitzt . Anderenfalls wird der aktuelle Datensatz zurückgeliefert . Die Implementierung des Iterators in der studentCollection-Klasse ist damit abgeschlossen. Nachstehend die komplette Deklaration.

class scIterator {
public:
   scIterator();
   scIterator(studentCollection::studentNode * initial);
   void advance();
   bool pastEnd();
   studentRecord student();
private:
   studentCollection::studentNode * current;
};

Nun können wir den Code mit einem Probedurchlauf testen. Zum Vergleich mit der alten Version implementieren wir die Berechnung des Durchschnittswerts:

scIterator iter;
   int gradeTotal = 0;
   int numRecords = 0;
iter = sc.firstItemIterator();
while (!iter.pastEnd()) {
   numRecords++;
   gradeTotal += iter.student().grade();
   iter.advance();
}
double average = (double) gradeTotal / numRecords;

Hier kommen alle Iteratormethoden zum Einsatz; das ist für diesen Test äußerst praktisch. Zunächst wird firstItemIterator aufgerufen, um den scIterator zu initialisieren . In der Abbruchbedingung der Schleife wird pastEnd aufgerufen . Um an die Note zu gelangen, rufen wir dann die student-Methode der Iteratorinstanz auf, die uns den aktuellen studentRecord liefert . Schließlich rufen wir die advance-Methode auf , um mit dem nächsten Datensatz fortzufahren. Wenn dieser Code funktioniert, dürfen wir einigermaßen sicher sein, dass die verschiedenen Methoden korrekt implementiert sind. Darüber hinaus haben wir ein solides Verständnis von Iteratoren erworben.

Analyse dieser Lösung

Dass der Code funktioniert, bedeutet noch lange nicht, dass es hieraus nichts mehr zu lernen gibt. Wir sollten unsere Lösung gründlich durchdenken, die positiven und negativen Auswirkungen abwägen und ins Auge fassen, die soeben implementierte Grundidee auszuweiten. Im vorliegenden Fall können wir feststellen, dass der Iterator die ursprüngliche Problemstellung, mit Client-Code eine Datensammlung effizient zu durchlaufen, bestens löst. Wenn die Implementierung stattgefunden hat, ist die Verwendung des Iterators elegant und sehr gut nachvollziehbar. Allerdings lässt sich kaum abstreiten, dass der ineffiziente Ansatz mit der recordAt-Methode um einiges leichter zu programmieren ist. Um zu entscheiden, ob in einer konkreten Situation die Implementierung eines Iterators angemessen ist, sollten wir bedenken, wie häufig Durchläufe stattfinden, wie viele Objekte die Liste typischerweise enthält usw. Bei wenigen Durchläufen und kurzen Listen spielt die Ineffizienz womöglich gar keine Rolle, wenn aber große Listen zu erwarten oder zumindest nicht auszuschließen sind, kann ein Iterator erforderlich werden.

Hätten wir uns dazu entschlossen, die list-Implementierung der STL zu verwenden, müssten wir uns natürlich auch nicht weiter den Kopf über die Schwierigkeiten der Iteratorimplementierung zerbrechen, da wir diese nicht selbst durchführen. Wenn wieder einmal Bedarf besteht, können wir guten Gewissens die fertige list-Klasse verwenden, ohne dabei das Gefühl zu haben, uns selbst hereinzulegen oder für künftige Schwierigkeiten zu sorgen, weil wir Listen und Iteratoren gründlich genug untersucht haben, um zu verstehen, was hinter den Kulissen vor sich geht, auch ohne den eigentlichen Quellcode gesehen zu haben.

Wenn wir weiterdenken, sind noch allgemeinere Anwendungen von Iteratoren und deren möglichen Beschränkungen vorstellbar. Beispielsweise könnte ein Iterator erforderlich sein, der nicht nur zum nachfolgenden, sondern auch zum vorhergehenden Element in einer studentCollection springen kann. Nun da wir wissen, wie ein Iterator funktioniert, ist erkennbar, dass dies mit der gegenwärtigen studentCollection-Implementierung nicht möglich ist. Der Iterator verweist auf einen bestimmten Knoten in der Liste und muss nur dem im aktuellen Knoten gespeicherten Verweis auf den nächsten Knoten folgen, um diesen zu erreichen. Um zum vorhergehenden Knoten zurückzugelangen, muss die gesamte Liste bis zu diesem Knoten erneut durchlaufen werden. Wir würden hier eine doppelt verkettete Liste benötigen, deren Knoten Zeiger in beide Richtungen besitzen, nämlich sowohl auf den vorhergehenden als auch auf den nachfolgenden Knoten. Dieser Gedanke lässt sich weiter verallgemeinern und führt zu verschiedenen Datenstrukturen, auf die Clients in unterschiedlicher Weise zugreifen können. Im Kapitel über Rekursion sind wir beispielsweise kurz dem Binärbaum begegnet. Ist es möglich, einem Client das effiziente Durchlaufen einer solchen Datenstruktur in ihrer Standardform zu gestatten? Falls nicht, wie ließe sich dieses Ziel erreichen? Und in welcher Reihenfolge soll ein Binärbaum eigentlich durchlaufen werden? Fragen wie diese zu durchdenken, hilft dabei, die Programmierkenntnisse zu erweitern. Dadurch werden nicht nur neue Fähigkeiten erworben, man lernt auch die Stärken und Schwächen der verschiedenen Komponenten besser kennen. Die Kenntnis von Pro und Kontra einer Komponente gestattet es, diese mit Bedacht einzusetzen. Werden die Beschränkungen eines bestimmten Lösungsansatzes nicht beachtet, landet man schnell in einer Sackgasse, und je mehr Sie über die verwendeten Komponenten wissen, umso unwahrscheinlicher wird es, dass Sie dort enden.

7.4 Auswahl eines Komponententyps

Wie die Beispiele zeigen, lässt sich ein und dieselbe Problemstellung mit verschiedenen Komponententypen lösen. So könnte etwa ein Entwurfsmuster eine Idee zur Lösung beinhalten, ein Algorithmus eine Implementierung dieser Idee umreißen (oder einer ähnlichen Idee, die dasselbe Problem löst), ein abstrakter Datentyp die Idee verkapseln und eine Klasse in einer Bibliothek eine ausgetestete Implementierung des abstrakten Datentyps enthalten. Wenn nun all dies Ausdruck derselben Idee ist, wie soll man sich dann für einen der Komponententypen entscheiden?

Ein wichtiger Gesichtspunkt ist der zur Integration der Komponente in unsere Lösung benötigte Arbeitsaufwand. Das Verlinken einer Klassenbibliothek ist häufig eine der schnellsten Methoden zur Lösung einer Aufgabe, während die Implementierung eines Algorithmus anhand einer Beschreibung durch Pseudocode viel Zeit in Anspruch nehmen kann. Die Flexibilität, die eine infrage kommende Komponente bietet, ist ein weiterer wichtiger Gesichtspunkt. Viele Komponenten stehen in hübscher vorgefertigter Form bereit, nach der Integration in ein Projekt muss der Programmierer jedoch feststellen, dass die Komponente zwar fast alles erledigt, was benötigt wird, aber eben nur fast. Beispielsweise liefert eine Methode das Resultat in einem falschen Format, das eine weitere Verarbeitung erforderlich macht. Wird die Komponente dennoch eingesetzt, steht möglicherweise weiterer Ärger ins Haus, bevor die Komponente endgültig verworfen wird und komplett neuer Code für die betroffenen Programmteile erstellt werden muss. Hat der Programmierer hingegen eine der abstrakteren Komponenten ausgewählt, wie z. B. ein Entwurfsmuster, wird die daraus resultierende Implementierung perfekt an die Aufgabenstellung angepasst sein, denn sie wird ja schließlich speziell dafür entwickelt.

Abbildung 7.1 fasst die Wechselwirkung zwischen diesen Faktoren zusammen. Code aus einer Bibliothek ist im Allgemeinen gebrauchsfertig, lässt sich aber nicht modifizieren. Er lässt sich nur indirekt durch den Einsatz von C++-Templates ändern oder könnte das Entwurfsmuster „Strategie” verwenden, dem Sie früher in diesem Kapitel bereits begegnet sind. Am anderen Ende der Skala wird ein Entwurfsmuster möglicherweise nur durch eine reine Idee repräsentiert („Eine Klasse, die nur eine einzige Instanz ihrer selbst erlaubt.”), was zwar maximale Flexibilität erlaubt, aber eben auch viel Arbeit für den Programmierer bedeutet.

Abb. 7.1: Flexibilität und Arbeitsaufwand

Hierbei handelt es sich natürlich nur um eine allgemeine Richtlinie, von der in Einzelfällen Abweichungen möglich sind. Vielleicht ist eine Klasse aus der Bibliothek so maschinennah programmiert, dass die Flexibilität nicht leidet. Wir könnten beispielsweise eine selbst entworfene Klasse als Wrapper für eine Containerklasse wie die list-Klasse verwenden, die genügend Funktionalität bietet, um auch die Fähigkeiten unserer Klasse zu erweitern, weil wir davon ausgehen dürfen, dass die list-Klasse das erledigen kann. Verwenden wir hingegen ein Entwurfsmuster, das früher schon zum Einsatz kam, müssen wir möglicherweise kaum neuen Code schreiben, sondern nur den vorhandenen anpassen.

Je mehr Erfahrung Sie bei der Verwendung von Komponenten haben, umso mehr Vertrauen dürfen Sie Ihrer Entscheidung für einen Komponententyp schenken. Bevor Sie diese Erfahrungen gesammelt haben, gilt die Faustregel, dass Sie einen Kompromiss zwischen Flexibilität und Arbeitsaufwand suchen müssen. Stellen Sie sich in einer konkreten Situation Fragen wie die folgenden:

Kann ich die Komponente unverändert verwenden oder ist zusätzlicher Code erforderlich, um sie in mein Projekt einzubinden?

Bin ich mir sicher, dass ich die Aufgabenstellung bzw. den Teil der Aufgabe, der sich auf die Komponente bezieht, in vollem Umfang verstanden habe und dass sich die Aufgabenstellung künftig nicht ändert?

Kann ich meine Programmierkenntnisse durch Verwendung dieser Komponente erweitern?

Die Beantwortung dieser Fragen wird Ihnen bei der Einschätzung des Arbeitsaufwands helfen und Ihnen einen Eindruck davon vermitteln, welche Vorteile Ihnen die verschiedenen Komponententypen bieten.

7.4.1 Komponentenwahl in der Praxis

Das Prinzip sollte nun klar sein, sehen wir uns also zur Veranschaulichung ein konkretes Beispiel an.

Aufgabe: Teilweise Sortierung

Ein studentRecord-Array soll nach Noten sortiert werden, es gibt dabei aber einen Haken. Ein anderer Programmteil weist Schülern, deren Datensatz nicht bewegt werden darf, eine Punktzahl von -1 zu. Die anderen Datensätze sollen also sortiert werden, wobei diejenigen mit der Note -1 exakt an ihrem angestammten Platz verbleiben müssen. Es ergibt sich also ein sortiertes Array, in das Datensätze mit der Punktzahl -1 eingestreut sind.

Diese Aufgabe ist nicht einfach und es gibt eine Menge denkbarer Lösungsansätze. Der Einfachheit halber beschränken wir uns auf zwei Komponententypen: Entweder wählen wir einen Algorithmus (wie etwa Insertionsort) und modifizieren diesen so, dass studentRecord-Instanzen mit der Note -1 übergangen werden, oder wir überlegen uns eine Möglichkeit, die qsort-Funktion der Standardbibliothek zur Lösung dieser Aufgabe zu verwenden. Eine Lösung ist mit beiden Ansätzen möglich. Da uns der Code des Insertionsort-Algorithmus bereits vertraut ist, sollte es nicht allzu schwierig sein, einige if-Anweisungen einzufügen, die Datensätze mit der Punktzahl -1 erkennen und überspringen. Wenn qsort die Arbeit für uns erledigen soll, ist allerdings eine Zwischenlösung notwendig. Wir müssen die Schülerdatensätze mit echten Noten in ein anderes Array kopieren, dieses mit qsort sortieren, und danach den Inhalt zurückkopieren, ohne die Schülerdatensätze mit der Note -1 anzutasten.

Wir wollen beide Ansätze verfolgen, um herauszufinden, wie sich die Wahl des Komponententyps auf den daraus resultierenden Code auswirkt. Wir betrachten zunächst eine modifizierte Version des Insertionsort-Algorithmus. Wie üblich gehen wir das Problem schrittweise an. Als Erstes vereinfachen wir die Aufgabe dadurch, dass Punktzahlen von -1 ausgeklammert werden und nur ein normales studentRecord-Array sortiert wird. In nachstehendem Code ist sra ein Array, das arraySize Instanzen des Typs studentRecord enthält:

int start = 0;
int end = arraySize - 1;
for (int i = start + 1; i <= end; i++) {
   for (int j = i; j > start && sra[j-1].grade() > sra[j].grade(); j--) {
      studentRecord temp = sra[j-1];
      sra[j-1] = sra[j];
      sra[j] = temp;
   }
}

Der Code ist dem Insertionsort für Ganzzahlen sehr ähnlich. Der Unterschied besteht darin, dass der Vergleich einen Aufruf der grade-Methode erfordert und dass das temporäre Objekt, das zum Austausch der Positionen verwendet wird, von einem anderen Datentyp ist . Der Code funktioniert tadellos, aber beim Testen dieses Codeabschnitts und der noch folgenden gibt es einen Vorbehalt zu beachten: Die studentRecord-Klasse überprüft die Einträge und wird eine Note von -1 nicht akzeptieren. Führen Sie also vorher bitte die notwendigen Änderungen durch. Wenn das erledigt ist, können wir diese Lösungsversion zum Abschluss bringen. Der Insertionsort-Algorithmus soll nun Datensätze mit der Note -1 übergehen. Leichter gesagt als getan! Beim normalen Insertionsort-Algorithmus werden stets im Array aneinandergrenzende Inhalte miteinander vertauscht (im obenstehenden Code j und j-1). Wenn aber Datensätze mit der Note -1 an ihrem ursprünglichen Platz verbleiben sollen, könnte der Eintrag, mit dem die Position vertauscht werden muss, beliebig weit entfernt sein.

Abbildung 7.2 verdeutlicht dieses Problem durch ein Beispiel. Sie zeigt das Array im Ausgangszustand, und die Pfeile markieren die Positionen der beiden ersten Datensätze, die vertauscht werden sollen, sich aber nicht nebeneinander befinden. Danach muss der letzte Datensatz (derjenige von „Art”) an Position [5] erst mit dem an Position [3] und dann mit dem an Position [0] vertauscht werden. In diesem Beispiel betreffen also alle Vertauschungen nicht nebeneinander befindliche Datensätze.

Abb. 7.2: Der Abstand zwischen den Datensätzen, die ausgetauscht werden sollen, kann beliebig groß werden.

Bei den Überlegungen zur Lösung dieser Aufgabenstellung suchte ich nach einer Analogie und wurde bei der Verarbeitung verketteter Listen fündig. Viele Algorithmen für verkettete Listen verwenden beim Durchlaufen der Liste nicht nur einen Zeiger auf den aktuellen Knoten, sondern auch noch einen weiteren Zeiger für den vorausgehenden Knoten. Am Ende der Schleife zum Durchlaufen der Liste wird dann dem Zeiger auf den vorhergehenden Knoten derjenige des aktuellen Knotens zugewiesen. Hier muss etwas Vergleichbares passieren. Wir müssen uns bei der linear fortschreitenden Suche nach dem nächsten „richtigen” Schülerdatensatz den letzten „richtigen” Datensatz merken. Die Umsetzung dieser Idee führt zu folgendem Code:

for (int i = start + 1; i <= end; i++) {
   if (sra[i].grade() != -1) {
      int rightswap = i;
      for (int leftswap = i - 1;
         leftswap >= start
         && (sra[leftswap].grade() > sra[rightswap].grade()
         || sra[leftswap].grade() == -1); leftswap--)
      {
         if(sra[leftswap].grade() != -1) {
            studentRecord temp = sra[leftswap];
            sra[leftswap] = sra[rightswap];
            sra[rightswap] = temp;
            rightswap = leftswap;
         }
      }
   }
}

Beim einfachen Insertionsort wird wiederholt ein unsortierter Wert in den ständig wachsenden sortierten Bereich des Arrays eingefügt. Die äußere Schleife wählt dabei den jeweils nächsten unsortierten Wert aus, der eingefügt werden soll. Diese Version des Codes prüft zunächst, ob die Note an Position i in der äußeren Schleife von -1 verschieden ist . Ist dies nicht der Fall, springen wir zum nächsten Datensatz und lassen diesen hier unangetastet. Haben wir hingegen festgestellt, dass der Datensatz an Position i vertauscht werden soll, initialisieren wir rightswap mit dieser Position . Nun beginnt die innere Schleife. Beim normalen Insertionsort wird bei jedem Schleifendurchlauf ein Objekt mit seinem Nachbarn vertauscht. Da wir jedoch Datensätze mit dem Notenwert -1 unbehelligt lassen, führen wir die Vertauschung nur dann durch, wenn sich an Position j ein Datensatz mit einem von -1 verschiedenem Notenwert befindet . Gegebenenfalls findet dann der Austausch zwischen den Positionen leftswap und rightswap statt und wir weisen rightswap den Wert von leftswap zu , um die nächste Vertauschung vorzubereiten, falls es eine solche gibt. Außerdem muss die Abbruchbedingung der inneren Schleife modifiziert werden. Normalerweise wird die innere Schleife beim Insertionsort beendet, sobald das untere Ende des Arrays erreicht ist oder ein Wert gefunden wird, der kleiner ist als derjenige, den wir einfügen wollen. Hier müssen wir eine logische ODER-Verknüpfung verwenden, um zu verhindern, dass die Schleife bei einem Notenwert von -1 vorzeitig beendet wird , da -1 kleiner ist als alle gültigen Notenwerte.

Der Code löst die Aufgabenstellung, hinterlässt jedoch einen üblen Nachgeschmack. Der Standardcode eines Insertionsorts ist, insbesondere wenn man die Arbeitsweise im Kern verstanden hat, leicht verständlich. Die modifizierte Version hingegen ist ausgesprochen unansehnlich und bedarf vermutlich einiger Kommentarzeilen, damit sie später noch verständlich ist. Ein Refactoring wäre hier wohl angebracht, aber wir probieren stattdessen den anderen Lösungsansatz für die Aufgabenstellung aus und werden dann dessen Verständlichkeit beurteilen.

Zunächst benötigen wir eine Vergleichsfunktion für qsort. Wir vergleichen zwei studentRecord-Instanzen und bilden die Differenz der beiden Notenwerte:

int compareStudentRecord(const void * voidA, const void * voidB) {
   studentRecord * recordA = (studentRecord *) voidA;
   studentRecord * recordB = (studentRecord *) voidB;
   return recordA->grade() - recordB->grade();
}

Nun können wir die Datensätze sortieren, und zwar in drei Phasen. Zuerst kopieren wir alle Datensätze mit einem von -1 verschiedenen Notenwert lückenlos in ein zweites Array. Dann rufen wir qsort auf, um dieses zweite Array zu sortieren. Zum Schluss kopieren wir die Datensätze aus dem sortierten Array zurück ins ursprüngliche Array und überspringen dabei die Datensätze mit einem Notenwert von -1. Das führt zu folgendem Code:

studentRecord *sortArray = new studentRecord[arraySize];
int sortArrayCount = 0;
for (int i = 0; i < arraySize; i++) {
   if (sra[i].grade() != -1) {
      sortArray[sortArrayCount] = sra[i];
      sortArrayCount++;
   }
}
qsort(sortArray, sortArrayCount, sizeof(studentRecord),
                                 compareStudentRecord);
sortArrayCount = 0;
for (int i = 0; i < arraySize; i++) {
   if (sra[i].grade() != -1) {
      sra[i] = sortArray[sortArrayCount];
      sortArrayCount++;
   }
}

Dieser Code ist etwa von gleicher Länge wie der vorhergehende, er ist jedoch unkomplizierter und besser verständlich. Wir deklarieren zunächst das zweite Array sortArray , das genauso groß ist wie das ursprüngliche Array, und initialisieren die Variable sortArrayCount mit 0 . Wir verwenden sie in der ersten Schleife, um zu zählen, wie viele Datensätze in das zweite Array kopiert werden. Innerhalb der Schleife weisen wir jeden Datensatz mit einem von -1 verschiedenen Notenwert dem nächsten freien Platz in sortArray zu und erhöhen dabei sortArrayCount jeweils um 1. Wenn die Schleife beendet ist, sortieren wir das Array . Die Variable sortArrayCount wird auf 0 zurückgesetzt , denn in der zweiten Schleife verwenden wir sie, um zu zählen, wie viele Datensätze wir vom zweiten Array zurück ins ursprüngliche Array kopiert haben. Beachten Sie, dass diese Schleife das ursprüngliche Array durchläuft, um die Datensätze zu finden, die ersetzt werden müssen . Würden wir das zweite Array durchlaufen, um die Datensätze ins ursprüngliche Array zu kopieren, bräuchten wir eine zweite innere Schleife, um nach dem nächsten Datensatz im ursprünglichen Array zu suchen, der ersetzt werden muss. Hierbei handelt es sich um ein weiteres Beispiel dafür, wie sehr die Betrachtungsweise eine Aufgabe erleichtern oder erschweren kann.

7.4.2 Vergleich der Ergebnisse

Beide Lösungen funktionieren und sind vernünftige Ansätze. Die erste Lösung, bei der ein modifizierter Insertionsort-Algorithmus einige Datensätze beim Sortieren an Ort und Stelle belässt, ist für die meisten Programmierer schwieriger zu programmieren und zu verstehen. Die zweite Lösung scheint weniger effizient zu sein, da es hier erforderlich ist, die Daten in ein zweites Array und wieder zurück zu kopieren. An dieser Stelle ist etwas Erfahrung mit der Analyse von Algorithmen nützlich. Nehmen wir an, es wären 10.000 Datensätze zu sortieren (wären es deutlich weniger, spielt die Effizienz kaum eine Rolle). Wir können uns zwar nicht sicher sein, welcher Algorithmus der qsort-Implementierung zugrunde liegt, aber im ungünstigsten Fall würde eine allgemeine Sortierung 100 Millionen Vertauschungen erforderlich machen, und im günstigsten Fall wären es rund 130.000. Unabhängig davon, wo genau wir auf dieser Skala letztlich liegen, wird das Hin- und Zurückkopieren von 10.000 Datensätzen im Vergleich zum Sortieren keine entscheidende Geschwindigkeitseinbuße verursachen. Wir müssen außerdem berücksichtigen, dass der Algorithmus der qsort-Implementierung effizienter als unser einfacher Insertionsort sein könnte und somit jeden Vorteil zunichtemacht, den die Vermeidung des Hin- und Zurückkopierens der Daten mit sich bringt.

In diesem Szenario scheint also der zweite Lösungsansatz, der qsort verwendet, der bessere zu sein. Er ist einfacher zu implementieren, einfacher zu verstehen und wird sich daher auch besser warten lassen. Außerdem dürfen wir davon ausgehen, dass die Geschwindigkeit mit derjenigen der ersten Lösung gleichauf liegt oder diese sogar übertrifft. Das Beste, was wir der ersten Lösung abgewinnen können, sind möglicherweise erworbene Fähigkeiten, die wir auf andere Problemstellungen anwenden können, während die zweite Lösung solche Einsichten dank ihrer Einfachheit nicht bietet. Wenn Sie bei der Programmierung zu dem Punkt gelangen, an dem Sie versuchen, einen maximalen Lerneffekt zu erzielen, sollten Sie der allgemeinen Regel folgen, abstraktere Komponenten wie Algorithmen oder Entwurfsmuster zu bevorzugen. Wenn Sie hingegen Ihre Effizienz maximieren möchten (oder unter Termindruck stehen), sind weniger abstrakte Komponenten besser geeignet und Sie sollten, falls möglich, auf vorgefertigten Code zurückgreifen. Wenn die Zeit es erlaubt, führt das Ausprobieren verschiedener Lösungsansätze, so wie wir es gerade durchgeführt haben, natürlich zu den besten Resultaten.

7.5 Übungen

Probieren Sie möglichst viele der Übungen aus. Wenn Sie es erst einmal in den Griff bekommen haben, sich mit neuen Komponenten vertraut zu machen, werden Ihre Programmierkenntnisse rasant wachsen.

7.1

Ein oft vorgebrachter Einwand gegen das Entwurfsmuster „Strategie/Policy” lautet, dass es erforderlich ist, gewisse Interna der Klasse zu offenbaren, wie z. B. Datentypen. Schreiben Sie das „Klassenvorsteher”-Programm vom Anfang dieses Kapitels so um, dass alle Vergleichsfunktionen Bestandteil der Klasse sind und durch Übergabe eines Codewerts (beispielsweise ein neuer enum-Typ) ausgewählt werden, statt die Vergleichsfunktion selbst als Parameter zu übergeben.

7.2

Schreiben Sie die studentCollection-Funktionen aus Kapitel 4 (addRecord und averageRecord) so um, dass statt der selbst implementierten verketteten Liste eine Klasse der C++-Bibliothek verwendet wird.

7.3

Betrachten Sie eine Sammlung von studentRecord-Instanzen. Ein bestimmter Datensatz soll sich anhand der Schüler-ID schnell auffinden lassen. Speichern Sie die Datensätze in einem Array, sortieren Sie das Array nach der Schüler-ID und erkunden und implementieren Sie eine Interpolationssuche.

7.4

Implementieren Sie für die Aufgabe aus Übung 7-3 einen abstrakten Datentyp, der eine beliebige Zahl an Objekten speichern kann und dessen einzelne Werte sich mittels eines Schlüsselworts abrufen lassen. Der Oberbegriff für eine solche Datenstruktur lautet Symboltabelle. Typische Implementierungen einer Symboltabelle sind beispielsweise binäre Suchbäume und Hashtabellen.

7.5

Implementieren Sie für die Aufgabe aus Übung 7-3 eine Lösung, die eine Klasse der C++-Bibliothek verwendet.

7.6

Nehmen Sie an, Sie arbeiten an einem Projekt, bei dem ein bestimmter studentRecord um ein Datenfeld erweitert werden soll, das den Titel der Hausarbeit, das Jahr der Einschulung oder einen bool-Wert aufnimmt, der angibt, ob der Schüler am Unterricht teilnimmt. Sie möchten der normalen studentRecord-Klasse nicht alle drei Felder hinzufügen, da Sie wissen, dass diese Datenfelder in den meisten Fällen nicht genutzt werden. Ihr erster Gedanke ist es, drei abgeleitete Klassen zu erstellen, die jeweils eines der Datenfelder enthalten und deren Bezeichnung um einen aussagekräftigen Namen erweitert wird, der das zusätzliche Datenfeld beschreibt. Dann erfahren Sie, dass einige Schülerdatensätze mehr als nur eines dieser zusätzlichen Felder enthalten, manche sogar alle drei. Für alle möglichen Fälle abgeleitete Klassen zu erstellen, wäre äußerst unpraktisch. Suchen Sie nach einem Entwurfsmuster, das dieses Problem angeht, und implementieren Sie es.

7.7

Entwickeln Sie für die Aufgabenstellung der Übung 7-6 eine Lösung, die statt des Entwurfsmusters Klassen aus der C++-Bibliothek verwendet. Beschränken Sie sich dabei nicht auf die drei Datenfelder, sondern versuchen Sie, eine allgemeine Lösung zu finden: eine Version der studentRecord-Klasse, die es erlaubt, den Instanzen beliebige zusätzliche Datenfelder hinzuzufügen. Wenn sr1 ein studentRecord ist, könnte der Client dann den Aufruf sr1.addExtraField("Titel","Zur Elektrodynamik bewegter Körper") durchführen, und der Aufruf sr1.retrieveField("Titel") würde "Zur Elektrodynamik bewegter Körper" zurückgeben.

7.8

Entwerfen Sie eine eigene Problemstellung: Lösen Sie eine bereits erledigte Aufgabenstellung erneut mittels einer anderen Komponente. Vergleichen Sie das Resultat mit Ihrer ursprünglichen Lösung.

Kapitel 8: Denken wie ein Programmierer

Es ist nun an der Zeit, die Themen der vorhergehenden Kapitel zusammenzufassen, um die Tour vom angehenden Programmierer zum ausgewachsenen Problemlöser zum Abschluss zu bringen.

Wir haben in den vorausgehenden Kapiteln Aufgabenstellungen aus den verschiedensten Gebieten gelöst. Ich denke, dass angehende Programmierer von den ausgewählten Themenkreisen am meisten profitieren, aber es gibt natürlich immer noch etwas dazuzulernen, und viele Problemstellungen erfordern Fähigkeiten, die dieses Buch nicht vermittelt. In diesem Kapitel kehren wir daher zum Ausgangspunkt zurück, den allgemeinen Konzepten zum Lösen von Problemstellungen und entwickeln mithilfe des gewonnenen Wissens ein Gesamtkonzept, um beliebige Programmieraufgaben in Angriff zu nehmen. Man könnte auch von einem allgemeinen Plan sprechen, aber auf gewisse Weise handelt es sich tatsächlich um einen ganz speziellen Plan: Es ist Ihr Plan, und zwar nur Ihrer. Wir werden außerdem die vielen Möglichkeiten zur Erweiterung Ihrer Fähigkeiten und Programmierkenntnisse erörtern.

8.1 Das Gesamtkonzept

Am Anfang des ersten Kapitels wurde die wichtigste Regel beim Lösen von Problemstellungen vorgestellt, nämlich immer einem Plan zu folgen. Oder präziser formuliert: Sie sollten immer Ihrem Plan folgen. Sie sollten sich eine Art Masterplan zurechtlegen, der Ihre Stärken maximal zum Tragen bringt und Ihre Schwächen minimiert, und dieses Konzept auf jede Aufgabenstellung anwenden, die es zu bewältigen gilt.

Im Laufe all der Jahre, in denen ich unterrichtet habe, sind mir Menschen mit den unterschiedlichsten Begabungen begegnet. Damit meine ich nicht einfach nur, dass manche Programmierer fähiger sind als andere, obwohl das natürlich stimmt. Auch zwischen Programmierern mit vergleichbaren Fähigkeiten gibt es enorme Unterschiede. Ich habe schon lange damit aufgehört, zu zählen, wie oft mich sich vormals abmühende Studenten damit überrascht haben, dass sie plötzlich eine bestimmte Fähigkeit beherrschten oder dass an sich sehr begabte Studenten auf bestimmten Gebieten Schwächen zeigten. Es gibt keine zwei identischen Fingerabdrücke, und ebenso wenig gibt es zwei Gehirne, die einander gleich sind, und was dem einen leicht fällt, ist für den anderen eine Herausforderung.

Stellen Sie sich vor, Sie sind Fußballtrainer und bereiten die Angriffstaktik für das nächste Spiel vor. Aufgrund ausgeprägten Verletzungspechs steht noch nicht fest, welcher der beiden Spielmacher zum Einsatz kommen kann. Beide sind ausgezeichnete Spieler, aber wie jeder Mensch bei allen Bestrebungen besitzen auch sie ihre Stärken und Schwächen. Eine Spieltaktik, die mit dem einen Spielmacher äußerst gewinnbringend wäre, könnte mit dem anderen katastrophal sein.

Beim Entwurf Ihres Gesamtkonzepts sind Sie der Trainer, und Ihre Fähigkeiten und Kenntnisse sind der Spielmacher. Um Ihre Siegchancen zu maximieren, benötigen Sie einen Plan, der sowohl Ihre Stärken als auch Ihre Schwächen berücksichtigt.

8.1.1 Stärken ausschöpfen, Schwächen lindern

Der entscheidende Schritt ist hier also, die eigenen Stärken und Schwächen zu erkennen. Das ist zwar nicht schwierig, erfordert aber einige Anstrengung und ein gewisses Maß an ehrlicher Selbsteinschätzung. Um aus Fehlern zu lernen, genügt es nicht, diese nur in den Programmen zu korrigieren, Sie müssen sich auch Notizen dazu machen, zumindest im Geiste, oder besser noch in einem Dokument. Auf diese Weise können Sie die eigenen Verhaltensmuster erkennen, die Ihnen sonst verborgen bleiben.

Ich werde nun die Schwächen in zwei Kategorien beschreiben: beim eigentlichen Programmieren und bei der Planung des Programms. Schwächen beim Programmieren machen sich dadurch bemerkbar, dass man beim Schreiben des Codes bestimmte Fehler wiederholt begeht. Beispielsweise schreiben viele Programmierer häufig Schleifen, die ein Mal zu oft oder ein Mal zu wenig durchlaufen werden. Dieses Phänomen ist in Anspielung auf ein altes Rätsel, bei dem es um die Anzahl der für einen 15 Meter langen Zaun mit 3 Meter langen Zaunlatten benötigten Zaunpfähle geht, unter dem Begriff Zaunpfahlproblem bekannt. Die impulsive Antwort vieler Menschen lautet oft fünf, aber wie Abbildung 8.1 unschwer zu entnehmen ist, sind es tatsächlich sechs.

Abb. 8.1: Das Zaunpfahlproblem

Die meisten Schwächen beim Programmieren erwachsen aus Situationen, in denen der Programmierer durch zu hastiges oder ungenügend vorbereitetes Schreiben des Codes semantische Fehler macht. Schwächen beim Entwurf zeigen sich hingegen schon in der Problemlösungs- oder Planungsphase. Sie könnten beispielsweise bemerken, dass es Ihnen schwerfällt, überhaupt anzufangen, oder dass die Integration vorhandener Unterprogramme beim Erstellen einer endgültigen Lösung Probleme bereitet.

Auch wenn sich kaum eine scharfe Grenze zwischen den beiden Kategorien ziehen lässt, gibt es dennoch die Tendenz, dass die Schwächen zu jeweils andersgearteten Schwierigkeiten führen, derer man sich auf unterschiedlichem Wege erwehren muss.

Schwächen bei der Programmierung verhindern

Die stundenlange Suche nach semantischen Fehlern, die im Handumdrehen behoben sind, wenn sie denn gefunden werden, ist vielleicht die frustrierendste Tätigkeit bei der Programmierung. Niemand arbeitet vollkommen fehlerlos, daher gibt es keine Möglichkeit, diese Fehlerquelle vollständig zu eliminieren. Ein guter Programmierer wird es jedoch zu verhindern wissen, denselben Fehler wieder und wieder zu begehen.

Ich kannte einen Programmierer, der es überdrüssig war, den wohl häufigsten semantischen Fehler bei der Programmierung in C++ zu begehen: die versehentliche Eingabe des Zuweisungsoperators (=) statt des Vergleichsoperators (==). Da der Ausdruck in einer Bedingung in C++ einen int-Wert besitzt (nicht nur bool), ist die folgende Anweisung syntaktisch korrekt:

if (number = 1) flag = true;

Hier wird die Ganzzahl 1 der Variablen number zugewiesen und dieser Wert 1 ist dann der Wert des Ausdrucks in der Bedingung, den C++ daher als true bewertet. Aber der Programmierer meinte natürlich eigentlich:

if (number == 1) flag = true;

Genervt davon, diese Art von Fehler wieder und wieder zu begehen, zwang der Programmierer sich, Tests auf Gleichheit immer in umgekehrter Reihenfolge einzugeben, also die Ziffer auf der linken Seite zu platzieren:

if (1 == number) flag = true;

Dies bewirkt, dass bei versehentlichem Weglassen eines der Gleichheitszeichen der Ausdruck 1 = number syntaktisch nicht mehr korrekt ist und beim Kompilieren zu einem Syntaxfehler führt. Der ursprüngliche Fehler stellt legale Syntax dar, also handelt es sich nur um einen semantischen Fehler, der beim Kompilieren nicht entdeckt wird. Da mir dieser Fehler selbst viele Male unterlaufen ist (und ich bei der Fehlersuche jedes Mal fast an die Decke gegangen bin), begann ich ebenfalls damit, diese Methode einzusetzen. Dann bemerkte ich etwas Seltsames. Da diese Schreibweise meinem üblichen Stil entgegenstand, musste ich beim Schreiben von Bedingungen immer wieder stutzen, wenn ich die Ziffer auf der linken Seite des Vergleichs eingab. Mir ging dann jedes Mal „Ich muss daran denken, die Ziffer links einzugeben, damit ich mich dabei ertappen kann, wenn ich versehentlich den Zuweisungsoperator eingebe” durch den Kopf. Wie Sie vielleicht schon vermutet haben, bewirkte dieser Gedanke, dass ich tatsächlich nie wieder versehentlich den Zuweisungsoperator, sondern immer den korrekten Vergleichsoperator eingab. Ich bin inzwischen zu meinem alten Stil zurückgekehrt, aber ich muss noch immer einen Moment innehalten und darüber nachdenken, was mich davor bewahrt, den falschen Operator einzutippen.

Man kann hieraus lernen, dass es oft schon genügt, sich seiner Schwächen beim Programmieren bewusst zu werden, um diese abzustellen. Das ist die gute Nachricht. Die schlechte lautet, dass Sie sich natürlich trotzdem die Mühe machen müssen, sich solcher Schwächen überhaupt erst bewusst zu werden. Die entscheidende Vorgehensweise besteht darin, sich zu fragen, wie es zu einem bestimmten Fehler kommen kann, statt diesen nur zu korrigieren und weiterzumachen wie zuvor. Sie erkennen dadurch die Grundregel, die Sie nicht korrekt befolgen. Nehmen wir beispielsweise an, Sie hätten folgende Funktion zur Berechnung des Durchschnittswerts der positiven Werte in einem Array von Ganzzahlen programmiert:

double averagePositive(int array[ARRAYSIZE]) {
   int total = 0;
   int positiveCount = 0;
   for (int i = 0; i < ARRAYSIZE; i++) {
      if (array[i] > 0) {
         total += array[i];
         positiveCount++;
      }
   }
return total / (double) positiveCount;
}

Auf den ersten Blick erscheint die Funktion in Ordnung zu sein, aber eine genauere Untersuchung zeigt, dass es ein Problem gibt. Wenn es im Array nämlich gar keine positiven Werte gibt, beträgt der Wert von positiveCount nach dem Durchlaufen der Schleife 0 und es kommt am Ende der Funktion zu einer Division durch 0 . Da es sich hier um eine Division von Gleitkommazahlen handelt, wird das Programm möglicherweise gar nicht abstürzen, sondern nur seltsames Verhalten an den Tag legen, je nachdem, wie der Rückgabewert der Funktion im übrigen Programm verwendet wird.

Wenn Sie nur daran interessiert sind, Ihren Code schnell zum Laufen zu bringen und Sie den Fehler entdecken, könnten Sie etwas Code hinzufügen, um den Fall zu berücksichtigen, dass positiveCount den Wert 0 besitzt, und fortfahren, als wäre nichts gewesen. Wenn Sie sich aber als Programmierer weiterentwickeln möchten, sollten Sie sich fragen, welchen Fehler Sie begangen haben. Natürlich besteht das Problem technisch betrachtet darin, dass die Möglichkeit einer Division durch 0 nicht berücksichtigt wird. Wenn die Analyse an dieser Stelle haltmacht, bringt Sie das zukünftig jedoch nicht weiter. Sie werden möglicherweise erneut einem Divisor begegnen, der den Wert 0 annehmen kann, und das Problem sicherlich erkennen können, aber solche Situationen sind eher selten. Stattdessen sollten wir feststellen, welche Grundregel verletzt wurde. Die Antwort lautet, dass immer auch Spezialfälle betrachtet werden müssen, die den Code vor Probleme stellen könnten.

Die Befolgung dieser Grundregel erleichtert es uns, in unseren Fehlern Regelmäßigkeiten oder Muster zu erkennen, und macht es dadurch wahrscheinlicher, dass wir diese Fehler zukünftig rechtzeitig entdecken. Die Frage „Gibt es die Möglichkeit einer Division durch 0?” zu stellen ist nicht annähernd so sinnvoll, wie sich zu fragen „Was sind bei diesen Daten die Sonderfälle?” Durch die allgemeinere Frage werden wir nicht nur daran erinnert, eine mögliche Division durch 0 zu prüfen, sondern auch die Fälle leerer Daten, Daten außerhalb eines Wertebereichs usw. zu berücksichtigen.

Den Schwächen bei der Planung Rechnung tragen

Um Schwächen bei der Planung des Programms zu vermeiden, ist eine andere Herangehensweise erforderlich. Der erste Schritt ist allerdings identisch: Sie müssen die Schwächen zunächst einmal aufspüren. Vielen Leuten fällt dieser Schritt schwer, weil sie es gar nicht mögen, einen so kritischen Blick auf sich selbst zu werfen. Wir sind darauf konditioniert, persönliche Schwächen zu verheimlichen. Die Sache ähnelt einem Bewerbungsgespräch, in dem Sie aufgefordert werden, Ihre größte Schwäche zu benennen, und Sie dann von der Sorge um die Qualität Ihrer Arbeitsleistung schwadronieren, anstatt eine wirkliche Schwäche preiszugeben. Aber ebenso wie Supermann nichts gegen Kryptonit ausrichten kann, besitzen auch die besten Programmierer echte Schwächen.

Es folgt eine Liste (die keineswegs Anspruch auf Vollständigkeit erhebt) von Beispielen für die Schwächen von Programmierern. Erkennen Sie sich in einer der Beschreibungen wieder?

Verworrenes Design Programmierer mit dieser Schwäche erstellen Programme mit zu vielen Bestandteilen oder Programme, die zu viele Arbeitsschritte benötigen. Die Programme funktionieren zwar, wirken aber alles andere als vertrauenserweckend – wie abgetragene, fadenscheinige Kleidung, die aussieht, als ob sie beim ersten Zupfen auseinanderzufallen droht – und sind eindeutig ineffizient.

Anlaufschwierigkeiten Diese Programmierer sind hochgradig träge. Sei es nun wegen mangelnden Selbstvertrauens beim Lösen von Problemstellungen oder aus reiner Verschleppungstaktik, jedenfalls dauert es anfangs viel zu lange, bis nennenswerte Fortschritte erzielt werden.

Keine/zu wenige Tests Dieser Programmierer mag es nicht, seinen Code formal zu testen. Häufig funktioniert der Code im Allgemeinen, nicht jedoch in Sonderfällen. Manchmal funktioniert der Code auch durchaus korrekt, skaliert aber nicht, wird also unzumutbar langsam oder versagt bei größeren Datenmengen, die der Programmierer nicht getestet hat.

Übersteigertes Selbstvertrauen Selbstvertrauen ist eigentlich eine feine Sache. Nicht zuletzt gehört es zu den Zielen dieses Buchs, das Vertrauen des Lesers in die eigenen Fähigkeiten zu steigern. Aber zu viel Selbstvertrauen ist manchmal genauso problematisch wie zu wenig. Ein übersteigertes Selbstvertrauen kann sich auf verschiedenste Weise bemerkbar machen. Der Programmierer nimmt beispielsweise eine unnötig komplizierte Lösung in Angriff oder will ein Projekt in zu kurzer Zeit abschließen, was zu hastig dahingeworfenen, fehlerdurchsetzten Programmen führt.

Schwächen in einzelnen Fachgebieten Diese Kategorie ist eine Art Sammelbegriff. Manche Programmierer liefern stets reibungslos funktionierende Resultate, bis sie mit ganz bestimmten Aufgabenstellungen konfrontiert werden. Denken Sie an alle bislang im Buch erörterten Themen. Die meisten Programmierer werden sich, selbst nach dem Absolvieren der Übungen, in manchen Gebieten erheblich sicherer fühlen als in anderen. Beispielsweise sind für den einen Programmierer Zeiger nur böhmische Dörfer, einem anderen hingegen ist Rekursion unbegreiflich. Und ein weiterer Programmierer hat Schwierigkeiten damit, komplexe Klassen zu erstellen. Er kann sich zwar irgendwie „durchwursteln” und die Aufgabe lösen, es ist aber ein holpriger Weg, wie eine Straße voller schlammiger Pfützen.

Es gibt verschiedene Möglichkeiten, den größten eigenen Schwächen entgegenzutreten, aber wenn Sie sie erst erkannt haben, ist es gar nicht schwer, sie zu umgehen. Wenn Sie beispielsweise zu den Programmierern gehören, die sich gerne vor Tests drücken, sollten Sie in Ihren Plan zur Erstellung der einzelnen Programmteile ausdrücklich einen Test aufnehmen und nicht fortfahren, bevor Sie diesen Punkt nicht abhaken können. Vielleicht wäre auch eine testgetriebene Entwicklung (englisch test-driven development) angebracht, bei der zunächst der Testcode geschrieben wird und erst danach der Code, der diese Tests bestehen muss. Wenn es Ihnen Schwierigkeiten bereitet, „in die Gänge” zu kommen, können Sie die Zerlegung in Teilaufgaben oder eine Reduktion der Aufgabe verwenden, um sofort Code zu schreiben, sollten sich aber darüber im Klaren sein, dass dieser eventuell später noch geändert werden muss. Wenn Ihre Entwürfe oft zu kompliziert ausfallen, sollten Sie Ihrem Plan einen eigenen Schritt hinzufügen, der ein Refactoring vorsieht. Egal welche Schwächen Sie als Programmierer auch haben, Sie können diese umgehen, wenn sie Ihnen nur bewusst sind. Ihre Schwächen sind dann nicht mehr wirkliche Schwächen, sondern nur noch Hindernisse, die Sie auf dem Weg zum erfolgreichen Abschluss eines Projekts umfahren müssen.

Stärken nutzen

Die Vermeidung von Fehlern ist entscheidend, um mit den Schwächen bei der Planung fertig zu werden. Eine gute Planung ist aber mehr als das. Es geht darum, mit den vorhandenen Fähigkeiten und unter den gegebenen Umständen ein bestmögliches Ergebnis zu erzielen. Sie müssen also insbesondere auch Ihre Stärken in das Gesamtkonzept mit einbeziehen.

Sie könnten nun vielleicht denken, dass dieser Abschnitt Sie nicht betrifft, oder zumindest noch nicht, denn wenn Sie dieses Buch lesen, sind Sie ja noch dabei, zu einem Programmierer zu werden. Möglicherweise fragen Sie sich sogar, ob Sie in dieser Phase Ihrer Entwicklung überhaupt schon Stärken ausbilden konnten. Ich versichere Ihnen, dass dem so ist, selbst wenn Sie diese noch gar nicht wahrgenommen haben. Es folgt eine Liste, die Anspruch auf Unvollständigkeit erhebt, mit Beschreibungen der typischen Stärken von Programmierern und Hinweisen, um zu erkennen, ob diese auf Sie zutreffen.

Blick für Details Dieser Typ Programmierer kann Sonderfälle voraussehen, erkennt Geschwindigkeitsprobleme, bevor sie überhaupt auftreten, und verliert angesichts des Gesamtbilds dennoch nie den Überblick über die wichtigen Einzelheiten, denen ein Programm Rechnung tragen muss, um eine vollständige und korrekte Lösung darzustellen. Programmierer mit dieser Stärke überprüfen ihre Ideen oft mit Papier und Bleistift, bevor sie mit dem Programmieren anfangen, schreiben ihren Code bedächtig und testen ihn häufig.

Schnelle Lerner Ein schneller Lerner erwirbt rasch neue Fähigkeiten, sei es ein neues Verfahren in einer bekannten Programmiersprache oder der Umgang mit neuen APIs. Er mag die Herausforderungen, die das Lernen neuer Dinge mit sich bringt, und wählt seine Projekte möglicherweise gemäß dieser Vorliebe aus.

Hochgeschwindigkeitsprogrammierer Der Hochgeschwindigkeitsprogrammierer muss nicht erst lange die Dokumentation wälzen, um eine neue Funktion auszuarbeiten. Sobald er mit dem Tippen anfängt, scheint ihm der Code nur so aus den Fingern zu fließen, der dabei kaum Syntaxfehler enthält.

Unermüdlich, unverdrossen Manche Programmierer betrachten hartnäckige Bugs als nicht hinnehmbare persönliche Beleidigung. Man könnte fast meinen, das Programm habe den Programmierer mit einem Lederhandschuh geohrfeigt und es sei nun an ihm, darauf zu reagieren. Dieser Typ Programmierer scheint immer nüchtern und pragmatisch, aber nie besonders frustriert zu sein. Er ist davon überzeugt, dass genügend Anstrengung schon zum Erfolg führen wird.

Überflieger Sie waren beim Kauf dieses Buches vermutlich noch kein Überflieger, aber da Sie nun über eine Orientierungshilfe verfügen, kommt der Stein jetzt vielleicht ins Rollen. Programmierer dieses Schlags beginnen schon beim Lesen einer Aufgabenstellung, potenzielle Lösungen zu durchdenken.

Bastler Für diesen Typ Programmierer kommt ein funktionierendes Programm einer wunderschönen Spielzeugkiste gleich. Der Bastler ist noch immer davon fasziniert, dass der Computer ihm aufs Wort gehorcht, und er erfreut sich daran, neue Dinge zu entdecken, die der Computer erledigen kann. Manchmal führt diese Bastelei dazu, dass einem bereits funktionierenden Programm mehr und mehr Funktionalitäten hinzugefügt werden. Man bezeichnet dieses Symptom dann etwas scherzhaft als „Featuritis” und ein Refactoring kann notwendig werden. Möglicherweise lassen sich auch Korrekturen vornehmen, um den Programmierer oder den Benutzer zufriedenzustellen.

Nur die wenigsten Programmierer weisen mehrere dieser Stärken auf, von denen sich manche sogar gegenseitig ausschließen. Die eine oder andere Stärke besitzt aber jeder Programmierer. Wenn Sie sich hier nirgends wiedererkennen, bedeutet das nur, dass Sie sich selbst noch besser kennenlernen müssen oder Ihre Stärke in einem Bereich liegt, der sich keiner meiner Kategorien zuordnen lässt.

Sobald Sie sich über Ihre Stärken im Klaren sind, müssen Sie diese in Ihr Gesamtkonzept mit einbeziehen. Wenn Sie beispielsweise ein Hochgeschwindigkeitsprogrammierer sind, liegt es auf der Hand, dass Sie dadurch das Ziel schneller erreichen, aber wie können Sie diese Stärke systematisch einsetzen? Im Bereich der formalen Softwareentwicklung gibt es ein Verfahren namens Rapid Prototyping, bei dem ein Programm anfangs ohne ausführliche Planung entwickelt und dann schrittweise verbessert wird, bis es den Anforderungen entspricht. Als Hochgeschwindigkeitsprogrammierer könnten Sie versuchen, diesen Ansatz zu verfolgen, und mit der Programmierung anfangen, sobald Sie die grundlegende Aufgabenstellung kennen. Design und Entwicklung des endgültigen Programmcodes orientieren sich dabei am grob entworfenen Prototyp des Programms.

Falls Sie ein schneller Lerner sind, sollten Sie sich vielleicht bei Projektbeginn auf die Jagd nach neuen Ressourcen oder Verfahren begeben, um die vorliegende Aufgabenstellung zu lösen. Wenn Sie kein schneller Lerner sind, sondern zu der Sorte von Programmierern gehören, die sich nicht gleich entmutigen lassen, sollten Sie sich zunächst mit den schwierigsten Themenkreisen befassen, um möglichst viel Zeit für deren Bewältigung nutzen zu können.

Welche Stärken Sie auch immer besitzen, nutzen Sie diese beim Programmieren unbedingt zu Ihrem Vorteil. Entwerfen Sie das Gesamtkonzept in einer Art und Weise, die es Ihnen gestattet, möglichst viel Zeit mit dem zuzubringen, was Sie am besten können. Dadurch erzielen Sie nicht nur das beste Ergebnis, sondern haben auch noch den größten Spaß dabei.

8.1.2 Aufbau des Gesamtkonzepts

Betrachten wir den Aufbau des Gesamtkonzepts etwas näher. Zu den Bestandteilen gehören sämtliche Verfahrensweisen zur Lösung von Problemstellungen, die wir entwickelt haben, sowie die Analyse der eigenen Stärken und Schwächen. In diesem Beispiel werde ich meine eigenen Stärken und Schwächen einbringen.

Ich werde hier alle im Buch erläuterten Verfahrensweisen anwenden, aber die Methode „Reduktion der Problemstellung” gefällt mir besonders gut, weil mir der Einsatz dieser Technik stets das Gefühl gibt, dass ich auf dem Weg zum angestrebten Ziel konkrete Fortschritte mache. Wenn ich mich gerade nicht in der Lage sehe, den Code zu schreiben, der die Aufgabenstellung vollständig löst, lasse ich einfach einen Teil davon unberücksichtigt, bis ich wieder Fahrt aufgenommen habe.

Meine größte Schwäche beim Programmieren ist Übereifer. Ich liebe es, zu programmieren, weil es mir einfach Freude bereitet, zu sehen, wie der Computer meinen Anweisungen Folge leistet. Manchmal führt dies dazu, dass mir ein Gedanke wie „Hey, das teste ich jetzt und guck mir an, was dabei herauskommt!” durch den Kopf geht, obwohl ich eigentlich erst die Richtigkeit des eben Geschriebenen überprüfen sollte. Das Gefährliche daran ist nicht, dass das Programm versagt, sondern dass es zu funktionieren scheint, aber die Spezialfälle nicht berücksichtigt, oder aber dass es funktioniert, jedoch nicht die beste Lösung darstellt, die ich hätte schreiben können.

Ich habe eine Vorliebe für elegante Programmdesigns, die sich leicht erweitern und wiederverwenden lassen. Beim Programmieren größerer Projekte verbringe ich häufig viel Zeit mit der Entwicklung alternativer Entwürfe. Das ist an sich nicht verwerflich, aber manchmal führt es dazu, dass ich zu viel Zeit mit der Entwurfsphase zubringe und nicht genügend Zeit verbleibt, um den ausgewählten Entwurf zu implementieren. In einigen Fällen ist die Lösung außerdem viel zu komplex, also eleganter, erweiterbarer und robuster, als sie es sein müsste. Da jedem Projekt Zeit und Geld nur in beschränktem Maße bereitstehen, ist die beste Lösung immer ein Kompromiss zwischen dem Wunsch nach hoher Softwarequalität und der Notwendigkeit, Ressourcen zu schonen.

Meine Stärken beim Programmieren sind (glaube ich jedenfalls) eine schnelle Auffassungsgabe und meine Freude am Lernen. Manche Programmierer mögen es, bestimmte Fähigkeiten immer wieder einzusetzen, ich hingegen bevorzuge Projekte, bei denen ich Neues lernen kann, und bin von dieser Herausforderung immer wieder hocherfreut.

Aus allen diesen Überlegungen folgt nun mein Gesamtkonzept für ein neues Projekt.

Um meiner größten Schwäche beim Entwerfen zu begegnen, werde ich die für die Designphase verfügbare Zeit streng begrenzen oder alternativ die Anzahl der verschiedenen zu prüfenden Entwürfe beschränken, bevor ich fortfahre. Manchem Leser erscheint diese Vorstellung vielleicht gefährlich. Sollten wir der Designphase nicht so viel Zeit wie möglich widmen, bevor wir mit der Programmierung beginnen? Scheitern die meisten Projekte nicht daran, dass vorab nicht genug Zeit investiert wird und dies am Ende zu einer Reihe von Kompromissen führt? Diese Bedenken sind gerechtfertigt, aber berücksichtigen Sie, dass ich hier keinen allgemeinen Leitfaden zur Softwareentwicklung erstelle, sondern mein eigenes, persönliches Gesamtkonzept zur Inangriffnahme von Programmieraufgaben. Meine Schwäche ist es, zu viel Zeit mit dem Design zu verbringen, nicht zu wenig. Für mich ergibt eine Regel zur Beschränkung dieser Zeit einen Sinn. Für einen anderen Programmierer könnte diese Regel desaströs sein, und wieder andere könnten eine Regel benötigen, die sie dazu zwingt, mehr Zeit für den Entwurf aufzubringen.

Nach Abschluss meiner anfänglichen Analyse werde ich prüfen, ob das Projekt die Möglichkeit bietet, neue Verfahren, Bibliotheken usw. kennenzulernen. Wenn das der Fall ist, werde ich ein kleines Testprogramm schreiben, um die neuen Fähigkeiten und Kenntnisse auszuprobieren, bevor ich sie bei der Entwicklung meiner Lösung einbeziehe.

Um den Übereifer zu bekämpfen, könnte ich mehrere kleine Code-Überprüfungen bei der Vollendung der einzelnen Module des Programms als Schritte in meinen Plan aufnehmen. Das wird mir allerdings eine gewisse Willenskraft abverlangen. Wenn ich ein Modul vollende, will ich es auch sofort ausprobieren. Darauf zu hoffen, dass ich mir das Ausprobieren jedes Mal versage, wäre damit vergleichbar, eine geöffnete Tüte Kartoffelchips neben einem hungrigen Mann zu belassen und sich dann zu wundern, dass die Tüte geleert wird. Es ist besser, die Schwächen durch Regeln zu umgehen, die den Programmierer nicht dazu zwingen, gegen seine Natur zu handeln. Ich könnte zwei Versionen des Projekts erstellen: eine Rohversion, in der alles erlaubt ist, und eine herausgeputzte für die Auslieferung. Wenn ich es mir gestatte, mit der ersten Version nach Herzenslust herumzuexperimentieren, aber untersage, nicht ausgetesteten Code in die zur Auslieferung vorgesehene Version zu übernehmen, werde ich meine Schwäche sehr viel wahrscheinlicher überwinden können.

8.2 Beliebige Aufgabenstellungen in Angriff nehmen

Sobald das Gesamtkonzept steht, sind wir auf alles vorbereitet. Darum geht es letztlich in diesem Buch: ausgehend von einer beliebigen Problemstellung eine Lösung zu finden. Die Beispielaufgaben in den vorhergehenden Kapiteln haben uns immer in eine bestimmte Richtung gedrängt, aber im wahren Leben ist bei einer Problemstellung nur selten vorgegeben, dass ein Array oder Rekursion zu verwenden ist oder dass ein Teil der Funktionalität eines Programms in einer Klasse verkapselt werden soll. Diese Entscheidungen trifft vielmehr der Programmierer beim Lösen der Problemstellung.

Auf den ersten Blick scheinen weniger Einschränkungen eine Aufgabe zu vereinfachen. Nun ist eine Einschränkung beim Design aber doch eine Randbedingung, und sind es nicht die Randbedingungen, die eine Aufgabe erst schwierig machen? Das ist zwar richtig, aber alle Aufgaben besitzen Randbedingungen, nur sind diese mal mehr und mal weniger deutlich formuliert. Wenn einem nicht gesagt wird, dass für eine bestimmte Aufgabe dynamische Datenstrukturen erforderlich sind, heißt das ja nicht, dass die Entscheidung gegen dynamische Datenstrukturen keine Auswirkungen haben kann. Die weiteren Randbedingungen, wie Geschwindigkeit, Modifizierbarkeit, verfügbare Zeit zur Entwicklung oder etwas anderes, könnten noch schwieriger oder gar nicht erfüllbar sein, wenn wir beim Entwurf die falsche Entscheidung treffen.

Stellen Sie sich vor, Sie sollten für eine Gruppe von Freunden einen Film aussuchen, den sich alle gemeinsam ansehen möchten. Wenn nun einer der Freunde unbedingt eine Komödie sehen will, ein weiterer keine älteren Filme mag und noch einer fünf Filme aufzählt, die er gerade gesehen hat und nicht schon wieder sehen möchte, erschwert dies Ihre Auswahl. Aber wenn keiner einen Vorschlag macht und es nur heißt „Such einen guten Film aus!”, ist die Aufgabe noch schwieriger, und Sie werden höchstwahrscheinlich einen Film auswählen, den mindestens eine Person in der Gruppe überhaupt nicht leiden kann.

Aus diesem Grund sind größere, weitläufig definierte und kaum eingeschränkte Aufgabenstellungen die schwierigsten. Sie sind jedoch ebenfalls für die Verfahrensweisen zur Lösung von Problemstellungen empfänglich, die wir im Buch durchgehend verwendet haben. Es dauert nur länger, sie zu lösen. Mit Ihrem Wissen um diese Verfahrensweisen und einem Gesamtkonzept in der Tasche werden Sie in der Lage sein, jede Aufgabenstellung zu bewältigen.

Um zu demonstrieren, was ich meine, werde ich nun die ersten Schritte eines Programms erläutern, mit dem man eine ganz spezielle Version „Galgenmännchen” spielen kann.

Bevor wir zur Beschreibung der Aufgabe kommen, möchte ich kurz die Grundregeln des Spiels zusammenfassen. Der erste Spieler wählt ein Wort aus und nennt dem zweiten Spieler die Anzahl der Buchstaben. Daraufhin versucht der zweite Spieler, einen Buchstaben zu erraten. Gelingt ihm das, offenbart der erste Spieler, an welcher Stelle der Buchstabe im gesuchten Wort vorkommt. Gibt es den Buchstaben mehrmals, müssen sämtliche Vorkommen benannt werden. Kommt der Buchstabe in dem Wort nicht vor, fügt der erste Spieler einem am Galgen baumelnden Strichmännchen einen Körperteil hinzu. Errät der zweite Spieler sämtliche Buchstaben des Worts, hat er gewonnen. Wenn jedoch vorher die Zeichnung des Strichmännchens vervollständigt wird, hat der erste Spieler gewonnen. Es gibt verschiedene Regeln, aus wie vielen Körperteilen das Galgenmännchen besteht. Man könnte also allgemeiner sagen, dass sich die Spieler vorab auf eine bestimmte Anzahl von Fehlversuchen einigen müssen, die den ersten Spieler zum Sieger machen.

Nun sind die Grundregeln geklärt und wir können uns die konkrete Aufgabenstellung ansehen, die eine gewisse Besonderheit besitzt.

Aufgabe: Schummeln beim Galgenmännchen

Schreiben Sie ein Programm, das den Part des ersten Spielers bei einer textbasierten Version des Galgenmännchens übernimmt (das heißt, Sie müssen kein Galgenmännchen zeichnen, sondern nur die Anzahl der Fehlversuche im Auge behalten). Der zweite Spieler legt den Schwierigkeitsgrad des Spiels durch die Länge des zu erratenden Worts und die Anzahl der zu seiner Niederlage führenden Fehlversuche fest.

Die Besonderheit bei diesem Programm ist die Tatsache, dass es schummelt. Statt am Anfang des Spiels tatsächlich ein Wort auszuwählen, vermeidet das Programm die Auswahl eines Worts, solange es bei der Niederlage des zweiten Spielers ein Wort anzeigen könnte, das den Informationen entspricht, die der zweite Spieler erhalten hat. Dabei müssen richtig geratene Buchstaben an der korrekten Position erscheinen und das Wort darf keinen der falsch geratenen Buchstaben enthalten. Wenn das Spiel beendet ist, zeigt der erste Spieler (das Programm) dem zweiten Spieler das ausgewählte Wort. Der zweite Spieler kann daher nicht beweisen, dass er betrogen wird. Allerdings ist die Wahrscheinlichkeit, dass der zweite Spieler gewinnt, gering.

Diese Aufgabenstellung ist, gemessen an echten Problemstellungen aus dem wahren Leben, zwar nicht gerade monströs, aber umfangreich genug, um die Schwierigkeiten zu demonstrieren, denen wir uns gegenübersehen, wenn eine Programmieraufgabe zwar Resultate vorgibt, aber keine Lösungsmethode. Sie könnten nun Ihre Entwicklungsumgebung starten und ausgehend von der Aufgabenstellung mit der Programmierung loslegen. Aber das wäre natürlich ein Fehler, denn wir wollen ja immer anhand eines Plans programmieren, daher muss ich nun mein Gesamtkonzept auf die vorliegende Situation anwenden.

Der erste Teil meines Gesamtkonzepts begrenzt die für die Designphase zur Verfügung stehende Zeit. Um diese Vorgabe wahr werden zu lassen, muss ich gründlich über den Entwurf nachdenken, bevor ich mit der Programmierung beginne. Ich glaube in diesem Fall allerdings, dass ich ein wenig herumexperimentieren muss, um eine Lösung für diese Aufgabe ausarbeiten zu können. Aber mein Gesamtkonzept erlaubt mir ja das Erstellen von zwei Programmen, nämlich eines provisorischen Prototyps und einer endgültigen, ausgefeilten Version. Ich werde es mir daher gestatten, jederzeit mit der Programmierung des Prototyps anzufangen, sogar vor der echten Arbeit am Entwurf. Ich werde es jedoch unterlassen, Code für die finale Version zu schreiben, solange ich nicht davon überzeugt bin, dass mein Entwurf korrekt ist. Das garantiert mir zwar nicht, dass ich mit dem Design des zweiten Projekts vollkommen zufrieden sein werde, bietet dafür aber die beste Möglichkeit.

Nun ist es an der Zeit, die Problemstellung in mehrere Teile zu zerlegen. In den anderen Kapiteln haben wir gelegentlich eine Liste der Teilaufgaben zusammengestellt, die zur Lösung der Gesamtaufgabe notwendig sind, und das würde ich jetzt auch gerne tun. Zu diesem Zeitpunkt wäre das jedoch sehr schwierig, denn ich weiß noch gar nicht, was genau das Programm eigentlich erledigen muss, um zu schummeln. Diesen Punkt muss ich näher untersuchen.

8.2.1 Wie man schummelt

Schummeln beim Galgenmännchen ist schon so speziell, dass ich nicht erwarte, auf der Suche nach Hilfe bei den üblichen Komponenten fündig zu werden. Ein Entwurfsmuster „Verachtenswerte Strategie” wird es kaum geben. Aber ich habe schon eine vage Vorstellung davon, wie das Schummeln funktionieren könnte. Ich denke, ich werde zu Beginn des Spiels irgendein Wort auswählen und dieses beibehalten, solange der zweite Spieler keinen der Buchstaben dieses Worts auswählt. Sobald der zweite Spieler einen Buchstaben errät, wechsle ich zu einem anderen Wort, sofern es eines gibt, das keinen der bislang gewählten Buchstaben besitzt. Mit anderen Worten: Ich bestreite gegenüber dem zweiten Spieler so lange wie möglich, dass es einen übereinstimmenden Buchstaben gibt. Das ist die Grundidee, aber ich brauche mehr als nur eine Idee, sondern etwas, das ich implementieren kann.

Um die Richtigkeit meiner Grundidee zu bestätigen, werde ich mit Papier und Bleistift ein Beispiel mit einer Wortliste durcharbeiten und dabei die Rolle des ersten Spielers einnehmen. Um die Sache nicht unnötig kompliziert zu machen, nehme ich dabei an, dass der zweite Spieler um ein Wort mit drei Buchstaben gebeten hat und dass die erste Spalte in Tabelle 8.1 alle mir bekannten Wörter mit drei (Klein-)Buchstaben enthält. Ich wähle als erstes Wort bar aus, das erste Wort in der Liste. Wenn der zweite Spieler nun einen anderen Buchstaben als b, a oder r nennt, sage ich einfach „Falsch” und bin der Vervollständigung des Galgenmännchens einen Schritt näher. Errät der zweite Spieler hingegen einen Buchstaben, wähle ich einfach ein neues Wort aus, das diesen Buchstaben nicht enthält.

Alle Wörter | Wörter ohne a | Wörter mit a
bar | gut | bar
gar | nur | gar
gut | rot | rar
nur |  | tat
rar |  | war
rot |  |
tat |  |
war |  |

Tabelle 8.1: Die Beispielwortliste

Nach einem Blick auf meine Wortliste bin ich mir allerdings nicht mehr sicher, ob diese Strategie die beste ist. In manchen Situationen ist sie sicherlich sinnvoll. Nehmen wir an, der zweite Spieler rät b. Kein anderes Wort in der Liste enthält diesen Buchstaben, sodass ich irgendeines der anderen Wörter als neues Wort auswählen kann. Gleichzeitig bedeutet das eine Schadensbegrenzung, denn ich muss nur eines der Wörter auf meiner Liste streichen. Aber was geschieht, wenn der zweite Spieler a rät? Wenn ich wieder „Falsch” sage, muss ich alle Wörter streichen, in denen ein a vorkommt, und es verbleiben nur noch die drei Wörter in der zweiten Spalte der Tabelle 8.1, aus denen ich auswählen kann. Wenn ich stattdessen zugebe, dass das Wort ein a enthält, stehen mir die fünf Wörter der dritten Spalte zur Auswahl. Beachten Sie jedoch, dass diese erweiterte Auswahl nur dadurch zustande kommt, dass sich das a bei allen fünf Wörtern an der gleichen Stelle befindet. Sobald ich bestätige, dass ein Buchstabe richtig ist, muss ich offenlegen, an welcher Position er sich befindet. Um meine Chancen für den Rest des Spiels stünde es deutlich besser, wenn mir möglichst viele Wörter zur Auswahl stehen, um auf weitere Rateversuche zu reagieren.

Selbst wenn es mir am Anfang des Spiels gelingt, keine Buchstaben preisgeben zu müssen, wird der zweite Spieler früher oder später doch einmal richtig raten. Der zweite Spieler könnte beispielsweise mit sämtlichen Vokalen beginnen. Ich muss also irgendwann entscheiden, was zu tun ist, wenn ein Buchstabe erraten wird. Wie mein Experiment mit der Beispielwortliste zeigt, muss ich offenbar die Position (oder Positionen) finden, an der der erratene Buchstabe am häufigsten vorkommt. Nach dieser Beobachtung wurde mir klar, dass ich eine falsche Vorstellung vom Schummeln hatte. Ich sollte besser gar kein Wort auswählen, auch nicht vorläufig, sondern nur auf die Wörter achtgeben, aus denen ich auswählen kann, wenn ich dazu gezwungen bin.

Nach diesen Überlegungen kann ich das Schummeln neu definieren: Die Wortliste, aus denen das Lösungswort ausgewählt wird, muss möglichst viele Einträge besitzen. Bei jedem Rateversuch des zweiten Spielers muss das Programm eine Entscheidung treffen. Sollen wir behaupten, dass der geratene Buchstabe falsch oder richtig war? Wenn er richtig war, an welcher Stelle befindet er sich? Mein Programm soll eine stetig schrumpfende Liste möglicher Lösungswörter verwenden und nach jedem Rateversuch eine Entscheidung treffen, die möglichst viele Wörter in dieser Liste belässt.

8.2.2 Erforderliche Operationen zum Schummeln beim Galgenmännchen

Nun habe ich die Aufgabenstellung gut genug verstanden, um eine Liste von Teilaufgaben zu erstellen. Bei einer Aufgabe dieser Komplexität ist es nicht unwahrscheinlich, dass auf der in dieser frühen Phase erstellten Liste noch einige Operationen fehlen. Das ist jedoch unproblematisch, denn mein Gesamtkonzept sieht vor, dass ich beim ersten Anlauf kein perfektes Design erreichen werde.

Verwaltung einer Wortliste Das Programm benötigt eine Wortliste, die es aus einer Datei einliest und intern speichert. Während des Spiels werden der Liste beim Schummeln Wörter entnommen oder die Liste wird dabei verkleinert.

Erstellen einer Liste von Wörtern bestimmter Länge Da ich beabsichtige, eine Liste mit potenziellen Lösungswörtern zu verwalten, muss ich eine Liste mit Wörtern erstellen, deren Länge vom zweiten Spieler festgelegt wird.

Geratene Buchstaben speichern Das Programm muss sich merken, welche Buchstaben bereits genannt wurden, wie viele davon falsch waren, und bei den als richtig erachteten Buchstaben feststellen, an welchen Positionen des Lösungsworts sich diese befinden.

Wörter zählen, in denen ein Buchstabe nicht vorkommt Um mir das Schummeln zu erleichtern, muss ich wissen, wie viele Wörter in der Liste den zuletzt genannten Buchstaben nicht enthalten. Schließlich soll das Programm die Entscheidung, ob der zuletzt genannte Buchstabe im Lösungswort vorkommt, so treffen, dass die größtmögliche Zahl potenzieller Lösungswörter in der Liste verbleibt.

Anhand des Buchstabens und dessen Position die größtmögliche Zahl von Wörtern feststellen Diese Operation scheint am kniffligsten zu sein. Nehmen wir an, der zweite Spieler hat gerade den Buchstaben s genannt und das Lösungswort ist drei Zeichen lang. Die Liste potenzieller Lösungswörter enthält jetzt vielleicht zehn Wörter, in denen ein s vorkommt, aber das spielt keine Rolle, denn das Programm muss angeben, wo im Lösungswort der Buchstabe auftritt. Lassen Sie uns das Vorkommen von Buchstaben in einem Wort als Muster bezeichnen. s?? stellt dann ein Muster dar, bei dem der erste Buchstabe ein s ist und die übrigen beiden Buchstaben irgendeinen anderen Buchstaben darstellen. Werfen Sie einen Blick auf Tabelle 8.2. Nehmen wir an, die Wortliste in der ersten Spalte enthält alle dem Programm bekannten Wörter mit drei Buchstaben, in denen ein s vorkommt. In den anderen Spalten ist diese Wortliste nach Mustern gefiltert. Das Muster ??s kommt in 10 Wörtern vor und ist damit am häufigsten. Diese Zahl muss verglichen werden mit der Anzahl der Wörter in der Liste der potenziellen Lösungswörter, die kein s enthalten, um zu entscheiden, ob der Rateversuch als falsch oder richtig beurteilt wird.

Erstellen einer Liste von Wörtern mit passendem Muster Wenn das Programm entscheidet, dass der Rateversuch des zweiten Spielers richtig ist, legt es eine neue Liste mit Lösungswörtern an, die nur Wörter enthält, die mit dem ausgewählten Muster übereinstimmen. Wenn wir den Buchstaben s im vorhergehenden Beispiel als korrekt bewerten würden, bestünde die neue Liste aus den Wörtern in der dritten Spalte der Tabelle 8.2.

Weiterspielen bis zum Ende Wenn all diese Operationen programmiert sind, muss ich Code schreiben, der diese miteinander verknüpft und dem Spielablauf folgt. Das Programm sollte den zweiten Spieler (den Benutzer) wiederholt zur Eingabe eines Buchstabens auffordern, dann herausfinden, ob die Liste der Lösungswörter beim Akzeptieren oder bei der Zurückweisung des Rateversuchs größer ist, die Wortliste dementsprechend verkleinern und schließlich das Lösungswort mit allen korrekt erratenen Buchstaben anzeigen. Dieser Vorgang wird so lange wiederholt, bis einer der beiden Spieler gewonnen hat, wofür ich auch noch eine Testbedingung finden muss.

Alle Wörter | ?ss | ??s | s?? | s?s
als | ass | als | sau | sos
ass | iss | aus | see |
aus |  | bis | sei |
bis |  | das | sie |
das |  | des | sud |
des |  | eis |  |
eis |  | las |  |
gar |  | los |  |
hut |  | mus |  |
ihn |  | was |  |
ihr |  |  |  |
iss |  |  |  |
ist |  |  |  |
las |  |  |  |
los |  |  |  |
mus |  |  |  |
sau |  |  |  |
see |  |  |  |
sei |  |  |  |
sie |  |  |  |
sos |  |  |  |
sud |  |  |  |
tot |  |  |  |
was |  |  |  |

Tabelle 8.2: Wörter mit drei Buchstaben

8.2.3 Der erste Entwurf

Auch wenn es so aussieht, als ob die vorhin erstellte Liste der erforderlichen Operationen nur eine Reihe von Tatsachen aufführt, werden dabei doch schon Designentscheidungen getroffen. Betrachten Sie beispielsweise die Operation „Erstellen einer Liste von Wörtern mit passendem Muster”. Diese Operation erscheint in meiner Lösung, oder wenigstens in dieser ersten Version davon, obwohl sie streng genommen gar nicht notwendig ist. Gleiches gilt für das „Erstellen einer Liste von Wörtern bestimmter Länge”. Statt eine immer kleiner werdende Liste potenzieller Lösungswörter zu verwalten, könnte ich auch die ursprüngliche Liste während des gesamten Spiels beibehalten, allerdings würde das die meisten anderen Operationen verkomplizieren. Die Operation „Wörter zählen, in denen ein Buchstabe nicht vorkommt” könnte dann nicht mehr einfach die Liste der potenziellen Lösungswörter durchlaufen und dabei die Wörter ohne den angegebenen Buchstaben zählen. Stattdessen müsste die Ausgangsliste durchsucht werden, und es wäre außerdem die Länge des Worts und das Vorkommen der schon erratenen Buchstaben zu überprüfen. Ich denke, der von mir eingeschlagene Weg ist insgesamt einfacher, aber ich muss mir der Tatsache bewusst sein, dass schon diese frühen Entscheidungen meinen endgültigen Entwurf beeinflussen.

Neben der Zerlegung in Teilaufgaben sind aber noch weitere Entscheidungen zu treffen.

Speichern der Wortliste Die Wortliste ist die wichtigste Datenstruktur des Programms, und sie wird beim Spielen ständig verkürzt. Bei der Auswahl der Datenstruktur fallen mir folgende Dinge auf. Erstens erwarte ich nicht, dass wahlfreier Zugriff auf die Liste erforderlich ist, sondern dass ich die Liste bei der Verarbeitung jeweils von vorne nach hinten durchlaufen kann. Zweitens ist mir die Größe der ursprünglichen Liste nicht bekannt. Drittens werde ich die Liste häufig verkleinern. Und viertens schließlich dürften die Methoden der string-Klasse in diesem Programm ziemlich nützlich sein. Wenn ich diese Beobachtungen zusammenfasse, komme ich zu dem Schluss, die list-Klasse der C++-STL mit dem Datentyp string zu verwenden.

Speichern der genannten Buchstaben Die ausgewählten Buchstaben können abstrakt als eine Menge betrachtet werden, das heißt, entweder wurde ein Buchstabe bereits ausgewählt oder auch nicht, und jeder Buchstabe darf nur ein Mal ausgewählt werden. Die Frage ist also, ob ein bestimmter Buchstabe des Alphabets Teil der Menge der bereits ausgewählten Buchstaben ist. Daher verwende ich für die Menge der ausgewählten Buchstaben ein bool-Array namens guessedLetters mit 26 Elementen. guessedLetter[0] ist also true, wenn der Buchstabe a während des Spiels bereits genannt wurde, und ansonsten false. guessedLetters[1] steht dann für b usw. Ich werde hier das im Buch durchgehend verwendete Verfahren zur Konvertierung von Kleinbuchstaben in die entsprechende Position im Array einsetzen. Wenn also letter eine char-Variable ist, die einen Kleinbuchstaben enthält, verweist guessedLetters[letter - 'a'] auf das zugehörige Element des Arrays.

Speichern der Muster Die Operation „Erstellen einer Liste von Wörtern mit passendem Muster” verwendet ein Muster mit der Position eines Buchstabens innerhalb eines Worts. Dieses Muster wird von einer anderen Operation erstellt, nämlich durch „Anhand des Buchstabens und dessen Position die größtmögliche Zahl von Wörtern feststellen”. Welches Format sollte ich dafür verwenden? Das Muster besteht aus einer Reihe von Zahlen, die angeben, an welcher Position ein Buchstabe vorkommt. Es gibt eine Vielzahl von Möglichkeiten, diese Zahlen zu speichern, aber der Einfachheit halber verwende ich eine weitere Liste, dieses Mal mit dem Datentyp int.

Soll ich eine Klasse programmieren? Ich schreibe das Programm in C++ und kann daher nach Gutdünken objektorientiert programmieren. Mein erster Gedanke ist, dass sich viele der Operationen in meiner Liste auf natürliche Weise zu einer Klasse zusammenfügen lassen, die z. B. wordList heißen könnte und Methoden zum Entfernen von Wörtern anhand bestimmter Kriterien (wie Länge oder Muster) besitzt. Da ich es allerdings vermeiden möchte, Entscheidungen zu treffen, die ich später wieder rückgängig machen muss, werde ich mein erstes provisorisches Programm vollständig prozedural programmieren. Sobald ich all die kniffligen Aspekte des Programms herausgearbeitet und tatsächlich Code für die Operationen in meiner Liste geschrieben habe, kann ich viel besser entscheiden, ob für die endgültige Version objektorientierte Programmierung angemessen ist.

8.2.4 Der erste Code

Der Spaß geht nun los! Ich starte meine Entwicklungsumgebung und mache mich an die Arbeit. Das Programm verwendet eine Reihe von Klassen aus der Standardbibliothek. Der Deutlichkeit halber werde ich diese zunächst einrichten:

#include <iostream>
using std::cin;
using std::cout;
using std::ios;
#include <fstream>
using std::ifstream;
#include <string>
using std::string;
#include <list>
using std::list;
using std::iterator;
#include <cstring>

Nun bin ich dazu bereit, die Operationen in meiner Liste zu implementieren. Bis zu einem gewissen Maße könnte ich die Operationen in beliebiger Reihenfolge programmieren, ich werde jedoch mit einer Funktion beginnen, die eine reine Textdatei mit Wörtern in die von mir ausgewählte list<string>-Datenstruktur einliest. An dieser Stelle fällt mir auf, dass mir noch eine Ausgangsliste mit Wörtern fehlt, denn diese möchte ich nicht selbst eingeben. Glücklicherweise liefert eine Internetsuche mit dem Stichwort „Wortlisten” genügend Websites, die Wortlisten in reinem Textformat mit jeweils einem Wort pro Zeile bereitstellen. Mir ist das Einlesen von Textdateien in C++ bereits vertraut, aber wäre es das nicht, würde ich ein kleines Testprogramm schreiben, um mit den verschiedenen Funktionen „herumzuspielen” und die erworbenen Kenntnisse dann in das Programm zum Schummeln beim Galgenmännchen einfließen lassen. Diese Vorgehensweise werde ich später im Kapitel noch erläutern.

Nachdem ich mir eine Wortliste besorgt habe, kann ich mit dem Schreiben der Funktion beginnen:

list<string> readWordFile(char * filename) {
   list<string> wordList;
   ifstream wordFile(filename, ios::in);
   if (wordFile == NULL) {
      cout << "Öffnen der Datei fehlgeschlagen. \n";
      return wordList;
   }
   char currentWord[30];
   while (wordFile >> currentWord) {
      if (strchr(currentWord, '\'') == 0) {
         string temp(currentWord);
         wordList.push_back(temp);
      }
   }
   return wordList;
}

Die Funktion ist nicht besonders kompliziert, daher nur einige wenige kurze Kommentare. Falls Sie noch keinem ifstream begegnet sind: Dieser Eingabestrom funktioniert genauso wie cin, liest aber statt aus der Standardeingabe aus einer Datei. Wenn der Konstruktor die Datei nicht öffnen kann (was normalerweise bedeutet, dass sie nicht gefunden wurde), ist die Instanz NULL, und das muss ich überprüfen . Ist die Datei vorhanden, wird sie in einer Schleife verarbeitet , die den Inhalt der Datei zeilenweise in ein char-Array einliest, dieses in eine string-Instanz konvertiert und schließlich der Wortliste hinzufügt. Die von mir verwendete Datei enthält Wörter, in denen einfache Anführungszeichen vorkommen, die beim Galgenmännchen ungültig wären. Solche Wörter werden daher aussortiert .

Nun kommt eine Funktion an die Reihe, die sämtliche Wörter in meiner list<string> ausgibt. Eine solche Funktion steht zwar nicht auf meiner Liste der erforderlichen Operationen, und ich werde sie beim Spielen auch nicht einsetzen (das würde nur meinem Gegenspieler zugutekommen, den ich ja schließlich hinters Licht führen möchte). Sie dient nur als Test, ob meine readWordFile-Funktion korrekt arbeitet:

void displayList(const list<string> & wordList) {
   list<string>::const_iterator iter;
   iter = wordList.begin();
   while (iter != wordList.end()) {
      cout << iter->c_str() << "\n";
      iter++;
   }
}

Im Wesentlichen handelt es sich hier um den in den vorhergehenden Kapiteln vorgestellten Code zum Durchlaufen einer Liste. Beachten Sie, dass der Parameter als const-Referenz deklariert wird. Die Liste kann anfangs ziemlich groß sein, und ein Referenzparameter reduziert den Aufwand des Funktionsaufrufs beträchtlich, denn würde die Liste als Wert übergeben, müsste sie vollständig kopiert werden. Durch die Deklaration als const wird signalisiert, dass die Funktion die Wortliste nicht ändert, was zur besseren Verständlichkeit des Codes beiträgt. Eine const-Liste benötigt auch einen Iterator vom Typ const . Der Ausgabestrom cout kann mit einer string-Instanz nichts anfangen, daher wird diese mittels c_str() in einen C-String umgewandelt .

Nach gleichem Muster erstelle ich auch die Funktion zum Zählen der Wörter, in denen ein bestimmter Buchstabe nicht vorkommt:

int countWordsWithoutLetter(const list<string> & wordList, char letter) {
   list<string>::const_iterator iter;
   int count = 0;
   iter = wordList.begin();
   while (iter != wordList.end()) {
      if (iter->find(letter) == string::npos) {
         count++;
      }
      iter++;
   }
   return count;
}

Wie Sie sehen, handelt es sich hier um die gleiche Art Schleife zum Durchlaufen der Liste wie zuvor. In der Schleife rufe ich die find-Methode der string-Klasse auf , die normalerweise die Position ihres char-Parameters innerhalb der string-Instanz zurückliefert, aber den speziellen Wert npos zurückgibt, wenn das Zeichen nicht gefunden wurde.

Zum Schreiben der Funktion, die alle Wörter aus der Liste entfernt, deren Länge nicht dem angegebenen Wert entspricht, verwende ich das bewährte Muster für die Schleife ein weiteres Mal:

void removeWordsOfWrongLength(list<string> & wordList,
                                 int acceptableLength) {
   list<string>::iterator iter;
   iter = wordList.begin();
   while (iter != wordList.end()) {
      if (iter->length() != acceptableLength) {
         iter = wordList.erase(iter);
      } else {
         iter++;
      }
   }
}

Diese Funktion ist ein schönes Beispiel dafür, dass jedes Programm, das man schreibt, die Gelegenheit bietet, die Arbeitsweise von Programmen besser zu verstehen. Es war mir möglich, diese Funktion ohne Umschweife zu schreiben, da ich aufgrund früher geschriebener Programme wusste, was hier hinter den Kulissen vor sich geht. Die Funktion verwendet den Code zum Durchlaufen der Liste wie in den vorhergehenden Funktionen, aber im Inneren der Schleife gibt es ein interessantes Detail. Die erase()-Methode entfernt das durch einen Iterator angegebene Objekt aus einer list-Instanz. Nun weiß ich dank der Implementierung eines Iterators für eine verkettete Liste in Kapitel 7, dass der Iterator nahezu mit Sicherheit ein Zeiger ist. Und durch die in Kapitel 4 gesammelten Erfahrungen im Umgang mit Zeigern weiß ich, dass verwaiste Zeiger, die auf gelöschte Objekte zeigen, nicht nur nutzlos, sondern oft auch gefährlich sind. Daher ist mir klar, dass ich iter nach dem Löschvorgang einen gültigen Wert zuweisen sollte. Glücklicherweise haben die Entwickler von erase() dieses Problem vorhergesehen und dafür gesorgt, dass die Methode einen neuen Iterator zurückliefert, der auf das dem gerade gelöschten Objekt unmittelbar folgende verweist, sodass ich diesen Wert an iter zuweisen kann . Beachten Sie außerdem, dass ich iter nur dann erhöhe , wenn ich das aktuelle Wort nicht aus der Liste gelöscht habe, weil die Erhöhung durch die Zuweisung des Rückgabewerts von erase() an iter faktisch bereits geschehen ist und ich schließlich keines der Wörter überspringen möchte.

Nun kommen wir zum schwierigen Teil, nämlich das häufigste Muster eines bestimmten Buchstabens in der verbliebenen Wortliste zu finden. Hier bietet sich ein weiteres Mal die Gelegenheit, eine Zerlegung in Teilaufgaben vorzunehmen. Mir ist bekannt, dass eine der Teilaufgaben bei dieser Operation darin besteht, zu überprüfen, ob ein bestimmtes Wort dem Muster entspricht. Wie Sie wissen, ist ein Muster vom Typ list<int>, wobei jede Ganzzahl die Position des Buchstabens im Wort angibt, an der dieser vorkommt. Damit ein Wort dem Muster entspricht, muss der Buchstabe aber nicht nur an der angegebenen Position vorkommen, er darf auch nicht an irgendeiner anderen Stelle im Wort auftreten. Mit diesem Gedanken im Hinterkopf werde ich nun Wörter auf Übereinstimmung mit dem Muster testen, indem ich jedes Wort Buchstabe für Buchstabe durcharbeite. Kommt der gesuchte Buchstabe vor, überprüfe ich, ob er im Muster an gleicher Position vorkommt; kommt ein anderer Buchstabe vor, stelle ich sicher, dass er im Muster an gleicher Position nicht vorhanden ist.

Um die Sache etwas zu vereinfachen, programmiere ich zunächst eine eigene Funktion, die überprüft, ob eine bestimmte Position in einem Muster vorkommt:

bool numberInPattern(const list<int> & pattern, int number) {
   list<int>::const_iterator iter;
   iter = pattern.begin();
   while (iter != pattern.end()) {
      if (*iter == number) {
         return true;
      }
      iter++;
   }
   return false;
}

Ausgehend von den vorhergehenden Funktionen ist der Code schnell geschrieben. Ich durchlaufe die Liste und suche nach number. Entweder finde ich diese und gebe true zurück, oder ich gelange ans Ende der Liste und liefere stattdessen false zurück. Nun kann ich den allgemeinen Test auf Übereinstimmung von Wort und Muster implementieren:

bool matchesPattern(string word, char letter, list<int> pattern) {
   for (int i = 0; i < word.length(); i++) {
      if (word[i] == letter) {
         if (!numberInPattern(pattern, i)) {
            return false;
         }
      } else {
         if (numberInPattern(pattern, i)) {
            return false;
         }
      }
   }
   return true;
}

Wie Sie sehen, folgt diese Funktion dem vorhin erläuterten Plan. Bei jedem Zeichen des Strings, das mit letter übereinstimmt, prüft der Code, ob die aktuelle Position im Muster vorhanden ist. Stimmt das Zeichen nicht mit letter überein, stellt der Code sicher, dass die Position im Muster nicht vorhanden ist. Wenn nur eine einzige Position nicht mit dem Muster übereinstimmt, wird das Wort zurückgewiesen. Anderenfalls wird das Ende des Worts erreicht und das Wort wird akzeptiert.

An dieser Stelle kommt mir der Gedanke, dass es einfacher wäre, das häufigste Muster zu finden, wenn alle Wörter der Liste den angegebenen Buchstaben enthalten. Ich schreibe daher schnell eine Funktion, die Wörter ohne den fraglichen Buchstaben aus der Liste entfernt:

void removeWordsWithoutLetter(list<string> & wordList,
                                  char requiredLetter) {
   list<string>::iterator iter;
   iter; iter = wordList.begin();
   while (iter != wordList.end()) {
      if (iter->find(requiredLetter) == string::npos) {
         iter = wordList.erase(iter);
      } else {
         iter++;
      }
   }
}

Der Code kombiniert die Ideen aus den anderen Funktionen. Wenn ich darüber nachdenke, werde ich die umgekehrte Funktion ebenfalls benötigen. Diese soll Wörter mit dem fraglichen Buchstaben aus der Liste löschen. Damit kann ich die Wortliste verkleinern, wenn das Programm den letzten Rateversuch als falsch erachtet.

void removeWordsWithLetter(list<string> & wordList, char forbiddenLetter) {
   list<string>::iterator iter;
   iter = wordList.begin();
   while (iter != wordList.end()) {
      if (iter->find(forbiddenLetter) != string::npos) {
         iter = wordList.erase(iter);
      } else {
         iter++;
      }
   }
}

Nun ist alles vorbereitet, um das häufigste Muster für einen bestimmten Buchstaben in der Wortliste herauszufinden. Ich habe mehrere Lösungsansätze erwogen und denjenigen gewählt, von dem ich denke, dass er am einfachsten zu implementieren sein wird. Zunächst rufe ich obige Funktion auf, um alle Wörter ohne den angegebenen Buchstaben zu entfernen. Dann nehme ich das erste Wort der Liste, stelle dessen Muster fest und zähle ab, wie viele andere Wörter in der Liste das gleiche Muster besitzen. Jedes dieser Wörter wird nach diesem Zählvorgang aus der Liste entfernt. Nun beginnt der Zählvorgang vor vorne, jedoch mit dem Wort, das sich nun am Anfang der Liste befindet. Das wird so lange wiederholt, bis die Liste leer ist. Das sieht dann folgendermaßen aus:

void mostFreqPatternByLetter(list<string> wordList, char letter,
                             list<int> & maxPattern,
                             int & maxPatternCount) {
   removeWordsWithoutLetter(wordList, letter);
   list<string>::iterator iter;
   maxPatternCount = 0;
   while (wordList.size() > 0) {
      iter = wordList.begin();
      list<int> currentPattern;
      for (int i = 0; i < iter->length(); i++) {
         if ((*iter)[i] == letter) {
            currentPattern.push_back(i);
         }
      }
      int currentPatternCount = 1;
      iter = wordList.erase(iter);
      while (iter != wordList.end()) {
         if (matchesPattern(*iter, letter, currentPattern)) {
            currentPatternCount++;
            iter = wordList.erase(iter);
         } else {
            iter++;
         }
      }
      if (currentPatternCount > maxPatternCount) {
         maxPatternCount = currentPatternCount;
         maxPattern = currentPattern;
      }
      currentPattern.clear();
   }
}

Die Liste wird als Wert (nicht als Referenz) übergeben , weil sie von der Funktion während der Verarbeitung nach und nach gekürzt wird und ich verhindern möchte, das der übergebene Parameter der aufrufenden Funktion davon in Mitleidenschaft gezogen wird. Beachten Sie, dass maxPattern und maxPatternCount nur ausgehenden Charakter besitzen; die beiden Parameter werden verwendet, um dem aufrufenden Code das am häufigsten vorkommende Muster und die Anzahl der Vorkommen mitzuteilen. Ich entferne nun alle Wörter ohne letter und erreiche die Hauptschleife der Funktion, die fortgesetzt wird, bis die Liste leer ist . Der Code in dieser Schleife besteht aus drei Abschnitten. Zunächst baut eine for-Schleife das Muster für das erste Wort in der Liste auf . Dann zählt eine while-Schleife, wie viele Wörter in der Liste diesem Muster entsprechen . Schließlich prüfen wir, ob diese Anzahl größer ist als die bislang höchste und verwenden dabei die aus Kapitel 3 bekannte „König der Murmelspieler”-Strategie.

Die letzte Hilfsfunktion, die ich noch brauche, soll alle bislang genannten Buchstaben anzeigen. Wie Sie wissen, sind diese in einem Array mit 26 bool-Werten gespeichert:

void displayGuessedLetters(bool letters[26]) {
   cout << "Bislang genannte Buchstaben: ";
   for (int i = 0; i < 26; i++) {
      if (letters[i]) cout << (char)('a' + i) << " ";
   }
   cout << "\n";
}

Ich addiere hier den Grundwert eines Wertebereichs (das Zeichen a) zu einem Wert eines anderen Wertebereichs , ein Verfahren, das wir in Kapitel 2 erstmals eingesetzt haben.

Nun kommt noch eine weitere Funktion namens reduceByPattern ins Spiel, die den anderen Funktionen zum Bearbeiten der Wortliste stark ähnelt und alle Wörter aus der Liste entfernt, die einem angegebenen Muster nicht entsprechen:

list<string> reduceByPattern(const list<string> & wordList,
                            char letter, list<int> pattern) {
   list<string> newList;
   list<string>::const_iterator iter;
   iter = wordList.begin();
   while (iter != wordList.end()) {
      if (matchesPattern(*iter, letter, pattern)) {
         newList.push_back(*iter);
      }
      iter++;
   }
   return newList;
}

Nun sind alle wichtigen Teilaufgaben erledigt und ich bin bereit zu versuchen, das Gesamtproblem in Angriff zu nehmen, allerdings gibt es noch eine Menge Funktionen, die nicht vollständig getestet wurden, was ich gerne schleunigst nachholen würde. Statt zu versuchen, die Gesamtproblemstellung in einem Schritt zu lösen, werde ich daher eine Reduktion vornehmen, indem ich für einige der Variablen, wie etwa die Länge des zu erratenden Worts, Konstanten einsetze.

Da ich die provisorische Version am Ende ohnehin entsorge, ist mir auch nicht unwohl dabei, die gesamte Programmlogik in der main-Funktion abzulegen. Da der Code insgesamt allerdings ziemlich umfangreich ist, präsentiere ich ihn stückweise.

int main () {
   list<string> wordList = readWordFile("wordlist.txt");
   const int wordLength = 8;
   const int maxMisses = 9;
   int misses = 0;
   int discoveredLetterCount = 0;
   removeWordsOfWrongLength(wordList, wordLength);
   char revealedWord[wordLength + 1] = "********";
   bool guessedLetters[26];
   for (int i = 0; i < 26; i++) guessedLetters[i] = false;
   char nextLetter;
   cout << "Bislang erraten: " << revealedWord << "\n";

In diesem ersten Codeabschnitt werden die Konstanten und Variablen eingerichtet, die zum Spielen benötigt werden. Der größte Teil dieses Codes ist selbsterklärend. Die Wortliste wird mittels einer Datei angelegt und auf eine bestimmte Länge, in diesem Fall auf den konstanten Wert 8, zurechtgestutzt . Die Variable misses speichert die Anzahl der Fehlversuche des zweiten Spielers und discoveredLetterCount die Anzahl der richtig geratenen Buchstaben (die sich beispielsweise um den Wert 2 erhöht, wenn ein erratener Buchstabe zweimal im Wort vorkommt). Die Variable revealedWord enthält das zu erratende Wort in der Form, in der es dem zweiten Spieler bekannt ist. Noch unbekannte Zeichen werden dabei durch ein Sternchen repräsentiert . Das bool-Array guessedLetters merkt sich die bislang genannten Buchstaben und eine Schleife setzt eingangs alle Werte auf false. Die Variable nextLetter schließlich speichert den aktuellen Rateversuch des zweiten Spielers. Dann gebe ich den anfänglichen „Spielstand” in Form der Variablen revealedWord aus und kann nun die Hauptschleife des Spiels starten.

while (discoveredLetterCount < wordLength && misses < maxMisses) {
   cout << "Bitte einen Buchstaben eingeben: ";
   cin >> nextLetter;
   guessedLetters[nextLetter - 'a'] = true;
   int missingCount = countWordsWithoutLetter(wordList, nextLetter);
   list<int> nextPattern;
   int nextPatternCount;
   mostFreqPatternByLetter(wordList, nextLetter,
                      nextPattern, nextPatternCount);
   if (missingCount > nextPatternCount) {
      removeWordsWithLetter(wordList, nextLetter);
      misses++;
   } else {
      list<int>::iterator iter = nextPattern.begin();
      while (iter != nextPattern.end()) {
         discoveredLetterCount++;
         revealedWord[*iter] = nextLetter;
         iter++;
      }
      wordList = reduceByPattern(wordList, nextLetter, nextPattern);
   }
   cout << "Bislang erraten: " << revealedWord << "\n";
   displayGuessedLetters(guessedLetters);
}

Es gibt zwei Ereignisse, durch die das Spiel beendet wird. Entweder errät der zweite Spieler sämtliche Buchstaben des Lösungsworts (das heißt, discoveredLetterCount wird genauso groß wie wordLength) oder aber die Fehlversuche des zweiten Spielers sorgen für die Vervollständigung der Galgenmännchenzeichnung, was bedeutet, dass misses den Wert maxMisses erreicht. Die Schleife wird also fortgesetzt, solange keine dieser Bedingungen erfüllt ist . Innerhalb der Schleife wird nach dem Einlesen des nächsten Rateversuchs das entsprechende Element im guessedLetters-Array aktualisiert . Jetzt wird mit dem Schummeln begonnen. Das Programm stellt mittels countWordsWithoutLetter fest, wie viele Wörter in der Liste übrig bleiben, wenn der Rateversuch als fehlerhaft bewertet wird. Dann überprüft es unter Verwendung von mostFreqPatternByLetter die maximale Anzahl verbleibender Wörter, wenn der Buchstabe als korrekt angesehen wird. Ist der erste der beiden Werte größer, werden die Wörter, in denen der Buchstabe vorkommt, aus der Liste entfernt und misses wird um 1 erhöht . Anderenfalls verwenden wir das von mostFreqPatternByLetter gelieferte Muster, um revealedWord zu aktualisieren, und entfernen die Wörter, die dem Muster nicht entsprechen, aus der Liste .

   if (misses == maxMisses) {
      cout << "Leider verloren. Das gesuchte Wort war „";
      cout << (wordList.cbegin())->c_str() << "”.\n";
   } else {
      cout << "Gewonnen! Lösungswort ist „" << revealedWord << "”.\n";
   }
   return 0;
}

Der nach dem Ende der Schleife noch verbleibende Code entscheidet der Abbruchbedingung der Schleife entsprechend, was zu tun ist. Entweder hat sich das Programm erfolgreich zum Sieg gemogelt oder aber der zweite Spieler zwingt das Programm entgegen allen Erwartungen, das vollständige Wort zu enthüllen. Wenn das Programm gewinnt, muss sich noch mindestens ein Wort in der Liste befinden, daher gebe ich einfach das erste Wort aus und behaupte, es sei dasjenige, das ich von jeher im Sinn hatte. Ein noch verschlageneres Programm könnte stattdessen eines der verbliebenen Wörter zufällig auswählen, um es dem Gegenspieler noch schwerer zu machen, die Schummelei zu entdecken.

8.2.5 Analyse der ersten Ergebnisse

Ich habe den ganzen Code eingegeben und getestet; er funktioniert, aber es gibt natürlich noch einiges daran zu verbessern. Von den Designentscheidungen einmal abgesehen, fehlen dem Programm noch viele Funktionalitäten. Der Benutzer kann weder die Wortlänge noch die Anzahl der erlaubten Fehlversuche festlegen. Es prüft auch nicht, ob der aktuelle Buchstabe schon eingegeben wurde oder ob es sich überhaupt um einen Kleinbuchstaben handelt. Der Benutzerschnittstelle fehlen auch noch andere Artigkeiten, wie beispielsweise die Anzeige der noch erlaubten Fehlversuche. Und es wäre natürlich auch angebracht, dem Spieler eine weitere Runde anzubieten, anstatt das Programm erneut starten zu müssen.

Wenn ich über die endgültige Version des Programms nachdenke, erwäge ich ernsthaft, ein objektorientiertes Design einzusetzen. Eine wordList-Klasse bietet sich hier auf natürliche Weise an. Außerdem erscheint mir die main-Funktion viel zu groß. Ich habe eine Vorliebe für modulares, leicht zu änderndes Design, das zu einer kurzen main-Funktion führen sollte, in der die eigentliche Arbeit auf die Unterprogramme verteilt wird. Meine main-Funktion müsste also in mehrere Funktionen aufgeteilt werden und auch einige meiner Designentscheidungen sollten neu durchdacht werden. Im Nachhinein erscheint beispielsweise das Speichern der Muster als list<int> etwas mühselig. Eventuell könnte ich stattdessen ein bool-Array wie für guessedLetters verwenden.

Vielleicht sollte ich mich auch nach einer ganz anderen Datenstruktur umsehen? Jetzt ist jedenfalls der richtige Zeitpunkt, um innezuhalten und zu überlegen, ob es beim Lösen dieser Problemstellung Gelegenheiten gibt, neue Verfahrensweisen zu erlernen. Ich frage mich, ob es nicht geeignete spezielle Datenstrukturen gibt, die ich noch nicht in meine Überlegungen einbezogen habe. Selbst wenn ich letztendlich doch bei meiner ursprünglichen Wahl bleibe, kann ich durch diese Erkundungen doch vieles lernen.

Alle diese Entscheidungen deuten sich zwar erst an, aber dennoch habe ich das Gefühl, mit meinem Projekt auf dem richtigen Weg zu sein. Mit einem funktionierenden Programm, das die grundlegenden Anforderungen der Aufgabenstellung erfüllt, ist bereits eine Menge erreicht. In der Gewissheit, schon eine Lösung parat zu haben und nur auf der Suche nach einer noch besseren zu sein, kann ich nun zuversichtlich mit den verschiedenen Designideen in der provisorischen Version meines Programms herumexperimentieren.

Tipp

Wiederherstellungspunkt erstellen

Die Windows-Betriebssysteme von Microsoft legen vor Installation oder Änderung von Systemkomponenten einen sogenannten Wiederherstellungspunkt an. Dabei werden Sicherheitskopien von wichtigen Systemdateien erstellt, wie beispielsweise der Registrierungsdatenbank. Falls die Installation oder Aktualisierung zu ernsthaften Problemen führt, kann man diese faktisch rückgängig machen, indem die Systemdateien durch diejenigen des Wiederherstellungspunkts ersetzt werden.

Ich empfehle dringend, eine vergleichbare Methode auf Ihren Quellcode anzuwenden. Wenn Sie beabsichtigen, an einem bereits funktionierenden Programm Änderungen vorzunehmen, sollten Sie eine Kopie des gesamten Projekts erstellen und nur an dieser Kopie Änderungen durchführen. Das bereitet keine Mühe und kann Ihnen viel Zeit sparen, wenn die Änderungen später aus dem Ruder laufen. Programmierer geraten hier leicht in eine Denkfalle: „Das habe ich schon mal geschafft, das werde ich schon wieder hinbekommen.” Das stimmt zwar für gewöhnlich, dennoch besteht zwischen dem Wissen, die Aufgabe erneut lösen zu können, und der Möglichkeit, den ursprünglichen Code sofort als Vorlage zu nutzen, ein großer Unterschied.

Sie können alternativ auch eine Software zur Versionsverwaltung einsetzen, die das Kopieren und Speichern der Dateien automatisiert. Derartige Software leistet erheblich mehr als ein Wiederherstellungspunkt. Beispielsweise erlauben es viele Versionsverwaltungssysteme, dass mehrere Programmierer unabhängig voneinander am selben Projekt arbeiten können. Dieses Thema sprengt den Rahmen dieses Buchs zwar bei Weitem, Sie sollten sich diese Werkzeuge aber während Ihrer Fortentwicklung als Programmierer durchaus etwas näher ansehen.

8.2.6 Die Kunst des Problemlösens

Haben Sie alle Vorgehensweisen zur Lösung von Problemstellungen erkannt, die ich bei meiner Lösung bisher angewendet habe? Zur Lösung der Aufgabenstellung hatte ich einen Plan, und einen Plan zu haben, ist immer am wichtigsten. Ich hatte mich dann entschlossen, bei der ersten Version meiner Lösung mit Bekanntem anzufangen, indem ich einige mir vertraute Datenstrukturen verwendete, nämlich Arrays und die list-Klasse. Eine Reduktion der Funktionalität vereinfachte es, meine provisorische Version zu programmieren, und ich konnte meinen Code auf diese Weise früher testen. Die Problemstellung habe ich in einzelne Operationen zerlegt, wobei jede dieser Operationen eine eigene Funktion übernimmt, sodass ich an den verschiedenen Programmteilen unabhängig voneinander arbeiten konnte. Als ich mir nicht darüber im Klaren war, wie das Schummeln funktioniert, habe ich experimentiert und konnte so „Schummeln” als „Die Wortliste, aus denen das Lösungswort ausgewählt wird, muss möglichst viele Einträge besitzen” umformulieren, was mir konkrete Ideen zur Programmierung lieferte. Und auch bei der Programmierung der Operationen an sich habe ich Vorgehensweisen eingesetzt, die den im Buch vorgestellten gleichen.

Ich habe es außerdem geschafft, mich nicht frustrieren zu lassen. Das müssen Sie mir schon glauben.

Bevor wir fortfahren, möchte ich betonen, dass hier die Schritte vorgeführt wurden, die ich unternommen habe, um zu dieser Phase bei der Lösung einer Problemstellung zu gelangen. Dabei handelt es sich nicht notwendigerweise um die gleichen Schritte, die Sie unternommen hätten. Der oben stehende Code ist nicht die Lösung schlechthin und nicht unbedingt besser als das, was Sie erarbeitet hätten. Ich möchte damit zeigen, dass jede Problemstellung, egal welchen Ausmaßes, durch Variation der im Buch vorgestellten grundlegenden Vorgehensweisen lösbar ist. Wenn Sie eine Aufgabenstellung in Angriff nehmen, die doppelt oder 10-mal so umfangreich ist, wird das womöglich Ihre Geduld auf die Probe stellen, aber Sie können sie lösen.

8.3 Programmierkenntnisse weiterentwickeln

Es gibt noch ein weiteres Thema, das erörtert werden soll. Wenn Sie die verschiedenen Techniken zur Lösung von Problemstellungen in diesem Buch bewältigen, unternehmen Sie damit einen entscheidenden Schritt in Richtung einer Karriere als Programmierer. Es gibt jedoch, wie in den meisten Berufen, kein endgültiges Ziel, denn Sie müssen immer danach streben, sich als Programmierer fortzuentwickeln. Sie sollten sich, wie bei allem, was die Programmierung betrifft, einen festen Plan zurechtlegen, um neue Fähigkeiten und Kenntnisse zu erwerben, und nicht nur darauf vertrauen, dass Sie hier und da schon irgendetwas „aufgabeln” werden.

In diesem Abschnitt geht es um verschiedene Fachgebiete, auf denen Sie neue Kenntnisse erwerben können, und um einige systematische Ansätze zum Erreichen dieses Ziels. Der rote Faden, der sich durch all diese Gebiete zieht, ist die praktische Umsetzung des Gelernten. Das ist der Grund dafür, dass jedes Kapitel mit Übungen endet, die Sie doch sicherlich durchgearbeitet haben, oder? Die Lektüre von Fachliteratur über Programmierung ist natürlich ein unverzichtbarer Schritt, um neue Konzepte zu erlernen, aber eben auch nur ein erster Schritt. Um ein neu erlerntes Verfahren vertrauensvoll auf eine Problemstellung im wahren Leben anwenden zu können, ist es erforderlich, dieses Verfahren zunächst an einer kleineren, künstlichen Aufgabenstellung auszuprobieren. Wie Sie wissen, ist eine der grundlegenden Vorgehensweisen bei komplexen Problemstellungen, diese entweder in Teilaufgaben zu zerlegen oder aber eine zeitweilige Reduktion der Aufgabe vorzunehmen, sodass die verschiedenen Zustände, mit denen wir es zu tun haben, nur noch ein einziges nicht-triviales Element besitzen. Schließlich möchten Sie ja nicht versuchen, eine nicht-triviale Aufgabe zu lösen und sich gleichzeitig erst die zur Lösung erforderlichen Kenntnisse anzueignen, weil Sie dann Ihre Aufmerksamkeit auf zwei schwierige Probleme aufteilen müssten.

8.3.1 Neue Programmiersprachen

Ich halte C++ für eine großartige Programmiersprache zum Erstellen von Code in Auslieferungsqualität. Im ersten Kapitel habe ich außerdem erläutert, warum die Sprache auch zum Lernen bestens geeignet ist. Jedoch gibt es keine Programmiersprache, die allen anderen in jeder Situation überlegen wäre. Gute Programmierer müssen daher mehrere Sprachen beherrschen.

Nehmen Sie sich zum Lernen Zeit

Bevor Sie versuchen, ernsthaft in einer neuen Programmiersprache Code zu schreiben, sollten Sie jeden freien Augenblick nutzen, um sich mit der Sprache zu befassen. Wenn Sie versuchen, eine nicht-triviale Aufgabe in einer Programmiersprache zu lösen, die Sie vorher noch nie verwendet haben, handeln Sie einer der wichtigen Grundregeln beim Lösen von Problemstellungen zuwider, nämlich Frustration zu vermeiden. Wenn Sie es sich zum Ziel gesetzt haben, eine neue Programmiersprache zu erlernen, sollten Sie das auch wirklich tun, bevor Sie versuchen, „richtige” Programme damit zu schreiben.

Natürlich hat man im wahren Leben nicht ständig die Kontrolle darüber, welche Projekte gerade anstehen. Es könnte ja jederzeit jemand mit der Aufforderung an uns herantreten, ein Programm in einer bestimmten Programmiersprache zu schreiben, das so kurzfristig zur Verfügung stehen soll, dass keine Zeit verbleibt, diese Sprache gemächlich zu studieren, bevor wir das eigentliche Problem in Angriff nehmen. Um solchen Situationen zu entgehen, ist es am besten, mit dem Erlernen anderer Programmiersprachen anzufangen, bevor Sie diese unbedingt beherrschen müssen. Befassen Sie sich mit Sprachen, die Ihr Interesse wecken oder von denen Sie glauben, dass Sie im Laufe Ihrer Karriere darin programmieren werden. Hier handelt es sich um eine weitere dieser Situationen, in denen eine Tätigkeit, die kurzfristig wie Zeitverschwendung anmutet, sich langfristig auszahlt. Selbst wenn sich herausstellt, dass die neu erlernte Sprache in naher Zukunft nicht zum Einsatz kommt, kann das Studium anderer Programmiersprachen auch Ihre Fähigkeiten in den Ihnen bereits bekannten Sprachen verbessern, weil Sie dazu gezwungen sind, neue und andere Sichtweisen einzunehmen, alte Gewohnheiten abzulegen, und sich Ihnen dadurch neue Perspektiven auf Ihre Kenntnisse öffnen. Stellen Sie sich das Ganze als das Programmierer-Pendant zu einem Training in mehreren Sportarten vor.

Mit Bekanntem anfangen

Wenn Sie damit anfangen, eine neue Programmiersprache zu erlernen, wissen Sie per definitionem nichts darüber. Ist es jedoch nicht die allererste Programmiersprache, die Sie verwenden, wissen Sie schon eine ganze Menge über Programmierung. Beim Lernen einer neuen Programmiersprache ist es daher ein sinnvoller erster Schritt, vertraute Codebeispiele aus Ihnen bekannten Sprachen in der neuen Sprache nachzuprogrammieren.

Wie eingangs erwähnt, sollten Sie nicht nur darüber lesen, sondern alles auch in die Tat umsetzen. Übertragen Sie beispielsweise in anderen Sprachen geschriebene Programme in die neue Sprache. Untersuchen Sie dabei systematisch die verschiedenen Sprachelemente wie Kontrollstrukturen, Klassen, Datenstrukturen usw. Das Ziel ist es hier, möglichst viel Ihres vorhandenen Wissens für die Verwendung der neuen Programmiersprache zu nutzen.

Unterschiede erforschen

Der nächste Schritt ist es, in der neuen Sprache nach Unterschieden zu bereits bekannten Sprachen zu suchen. Auch wenn zwei Hochsprachen weitreichende Gemeinsamkeiten aufweisen, irgendetwas muss bei der neuen Sprache anders sein, denn sonst gäbe es keinen Grund, diese Sprache einer anderen vorzuziehen. Setzen Sie auch hier alles in die Praxis um. Wenn Sie beispielsweise nur davon lesen, dass die Anweisung zur Mehrfachauswahl einer Sprache Wertebereiche (statt der einzelnen Werte der switch-Anweisung in C++) erlaubt, ist das nicht annähernd so lehrreich, wie ein wenig Code zu schreiben, der diese Möglichkeit sinnvoll nutzt.

Es liegt auf der Hand, dass dieser Schritt bei Programmiersprachen, die bekannten Sprachen auffallend unähnlich sind, besonders wichtig ist. Er ist aber ebenso sehr von Bedeutung, wenn Sprachen einen gemeinsamen „Vorfahren” besitzen, wie z. B. C++, C#, Objective-C und Java, die alle objektorientierte Abkömmlinge von C sind. Ähnlichkeiten bei der Syntax verleiten Sie dann zum Glauben, mehr über die neue Sprache zu wissen, als es tatsächlich der Fall ist. Betrachten Sie folgenden Code:

integerListClass numberList;
numberList.addInteger(15);

Wenn man das als C++ interpretiert, wäre klar, dass die erste Zeile eine Instanz numberList der Klasse integerListClass anlegt und die zweite Zeile die addInteger-Methode dieser Instanz aufruft. Falls eine Klasse dieses Namens tatsächlich vorhanden ist und eine solche Methode besitzt, die eine Ganzzahl als Parameter erwartet, ist das absolut sinnvoller Code. Nehmen Sie nun an, ich erkläre Ihnen, dass der Code nicht in C++, sondern in Java geschrieben wurde. Syntaktisch ist an den beiden Codezeilen nichts auszusetzen. In Java wird allerdings allein durch die Deklaration noch keine Instanz der Klasse erzeugt, weil Objektvariablen eigentlich Referenzen sind, das heißt, sie verhalten sich ähnlich wie Zeiger. Das Java-Pendant der beiden Zeilen müsste wie folgt lauten:

integerListClass numberList = new integerListClass;
numberList.addInteger(15);

Diese spezielle Diskrepanz zwischen Java und C++ werden Sie wahrscheinlich schnell begreifen, es gibt aber sehr viel subtilere Unterschiede. Solche Unterschiede können das Debuggen in der neuen Sprache erheblich erschweren, wenn Sie sich nicht die Zeit nehmen, diese Unterschiede auch aufzuspüren. Bei der Durchsicht des Codes werden Sie problematische Stellen schlicht überlesen, weil Sie nicht über die notwendigen Informationen verfügen, um sie zu erkennen.

Vorbildlicher Code

Ich habe immer wieder betont, dass Sie nicht versuchen sollten, das Programmieren durch Übernahme und Ändern fremden Codes zu erlernen. Hin und wieder ist es jedoch unverzichtbar, den Code anderer Programmierer zu analysieren. Sie können Ihre Fähigkeiten durch das Schreiben einer Reihe eigener Programme allmählich ausbauen, um aber eine neue Programmiersprache wirklich zu meistern, sollten Sie sich den Code in dieser Sprache hoch qualifizierter Programmierer ansehen.

Sie werden solchen Code aber nicht stibitzen oder sich Teile des Codes ausleihen, um eine konkrete Aufgabe zu lösen. Sie erforschen stattdessen den vorhandenen Code, um die optimalen Vorgehensweisen in dieser Sprache aufzuspüren. Sie sollten sich beim Betrachten des Codes nicht nur fragen, was der erfahrene Programmierer durchführt, sondern auch, warum er es so und nicht anders macht. Noch besser ist es, wenn der Code Kommentare des Entwicklers enthält. Sie müssen hier zwischen Programmierstil und Geschwindigkeitsvorteilen unterscheiden, um eine typische Schwierigkeit zu vermeiden. Allzu oft erlernen Programmierer nur das Allernotwendigste einer neuen Programmiersprache und das führt zu kümmerlichem Code, der die Möglichkeiten dieser Sprache nicht ausschöpft. Wenn Sie als C++-Programmierer beispielsweise Code in Java schreiben müssen, sollten Sie sich nicht damit zufriedengeben, nur eine Art C++-Kauderwelsch zu verfassen, das als Java durchgeht. Lernen Sie stattdessen, richtigen Java-Code zu schreiben, so wie ihn ein Java-Programmierer erstellen würde.

Auch hier ist es wieder unerlässlich, das Gelernte in die Tat umsetzen. Modifizieren Sie vorhandenen Code und probieren Sie Neues aus. Versuchen Sie, den ursprünglichen Code selbstständig zu reproduzieren, ohne dabei nachzulesen. Ziel ist es, mit dem Code so vertraut zu werden, dass Sie Fragen anderer Programmierer dazu beantworten können.

Ich muss hier betonen, dass dieser Schritt erst nach allen anderen erfolgt. Bevor man beim Erlernen einer neuen Sprache den Code anderer Programmierer erforscht, müssen Syntax und Sprachregeln dieser Sprache erlernt werden, und auch die in anderen Sprachen erworbenen Kenntnisse beim Lösen von Problemstellungen sollten Anwendung finden. Wenn man versucht, diesen Lernprozess zu verkürzen, indem man schon zu Beginn lange Programmbeispiele untersucht und modifiziert, besteht die Gefahr, dass die Kenntnisse der neuen Sprache genau darauf beschränkt bleiben.

8.3.2 Kenntnisse in bekannten Programmiersprachen erweitern

Wenn Sie den Punkt erreicht haben, an dem Sie von sich behaupten können, eine Programmiersprache zu „kennen”, heißt das noch lange nicht, dass Sie alles über diese Sprache wissen. Auch wenn Sie die Syntax der Sprache bewältigt haben, wird es doch immer neue Möglichkeiten geben, die vorhandenen Funktionalitäten der Sprache zu kombinieren, um Problemstellungen zu lösen. Die meisten dieser neuen Möglichkeiten gehören in eine der Kategorien von Komponenten, die im letzten Kapitel vorgestellt wurden. Dort wurde erläutert, wie sich die Kenntnisse über Komponenten erweitern lassen. Der entscheidende Faktor sind die dabei unternommenen Anstrengungen. Wenn man beim Lösen von Problemstellungen mit nur einigen wenigen Methoden gute Erfahrungen gesammelt hat, ist es nur allzu einfach, sich auf das gewonnene Wissen zu verlassen, andere Methoden zu vernachlässigen und sich als Programmierer nicht mehr weiterzuentwickeln. Es verhält sich wie bei einem Fußballspieler, der zwar die gefährlichsten Freistöße und Eckbälle schießen kann, aber Schwächen beim Dribbeln und Passen oder bei Kopfbällen zeigt. Möchte der Spieler die Ersatzbank verlassen und sich einen Platz als Stammspieler erobern, muss er mehr können.

Um das meiste aus sich herauszuholen, müssen Sie ständig nach neuem Wissen und neuen Vorgehensweisen Ausschau halten und beides auch in die Praxis umsetzen. Suchen Sie nach Herausforderungen und meistern Sie diese! Erforschen Sie auch die Arbeiten von hoch qualifizierten Programmierern in den von Ihnen bevorzugten Sprachen.

Not macht bekanntlich erfinderisch. Suchen Sie sich Aufgabenstellungen, die Sie mit Ihren bislang erworbenen Fähigkeiten und Kenntnissen nicht zufriedenstellend lösen können. Manchmal lassen sich bereits gelöste Problemstellungen ändern, um eine neue Herausforderung zu bieten. Vielleicht haben Sie beispielsweise ein Programm geschrieben, das bestens funktioniert, sofern nur wenige Daten vorliegen, aber was geschieht, wenn plötzlich ungeheure Datenmengen zu verarbeiten sind? Oder was tun Sie, wenn ein Programm, das seine Daten auf der lokalen Festplatte ablegt, diese nun auf einem entfernten Rechner speichern soll? Und was, wenn mehrere Instanzen dieses Programms gleichzeitig auf diese Daten zugreifen sollen? Wenn Sie ein bereits funktionierendes Programm um weitere Funktionalitäten erweitern, können Sie sich voll und ganz auf die neuen Aspekte der Programmierung konzentrieren.

8.3.3 Zusätzliche Bibliotheken

Moderne Programmiersprachen sind untrennbar mit ihren Kernbibliotheken verbunden. Beim Erlernen von C++ werden Sie beispielsweise unweigerlich auf die STL (Standard Template Library) stoßen, und wenn Sie sich mit Java befassen, werden Sie mit Javas Standardklassen in Berührung kommen. Neben den standardmäßig zur Sprache gehörenden Bibliotheken werden Sie aber auch Bibliotheken von Drittherstellern begegnen. Dabei kann es sich um allgemeines Rüstzeug zur Programmierung handeln, wie z. B. Microsofts .NET-Framework, das zusammen mit verschiedenen Hochsprachen einsetzbar ist. Andere Bibliotheken sind auf bestimmte Gebiete beschränkt, wie etwa die Grafikbibliothek OpenGL, oder gehören zu einem proprietären Softwarepaket eines Drittherstellers. Was für eine neue Programmiersprache gilt, trifft auch auf eine Bibliothek zu: Sie sollten nicht versuchen, diese in einem größeren Projekt zu erlernen, das von der fraglichen Bibliothek Gebrauch macht. Machen Sie sich stattdessen mit den wichtigsten Komponenten der Bibliothek lieber durch ein unbedeutendes Testprojekt vertraut, bevor Sie sie in einem echten Projekt einsetzen. Stellen Sie sich eine Reihe zunehmend schwieriger Aufgaben, aber behalten Sie dabei im Auge, dass es nicht Sinn der Sache ist, diese Aufgaben komplett zu lösen, sondern dass ein Lernprozess stattfindet. Sie müssen Ihre Lösungen daher nicht herausputzen oder die selbst gestellten Aufgaben überhaupt vollständig lösen, sofern Sie den jeweiligen Teil der Bibliothek erfolgreich in Ihr Programm integrieren können. Auf diese Programme können Sie bei nachfolgenden Arbeiten zurückgreifen. Wenn Sie feststellen, dass Sie nicht weiterkommen, weil Sie sich nicht mehr daran erinnern können, wie beispielsweise in OpenGL eine 2D-Ansicht einer 3D-Szene überlagert wird, gibt es nichts Besseres, als ein altes Programm zu öffnen, das nur zur Demonstration eben dieser Technik erstellt wurde, und zwar in Ihrem eigenen Stil, da Sie es ja schließlich selbst programmiert haben.

Wie im Fall einer Programmiersprache sollten Sie, sobald Sie hinreichend mit der Bibliothek vertraut sind, den Code von Experten im Umgang mit dieser Bibliothek studieren. Bei den meisten größeren Bibliotheken gibt es Vorbehalte und Eigenarten, die der offiziellen Dokumentation nicht entnehmbar sind und die sich, wenn nicht durch lange Erfahrung, nur durch Kontakte mit anderen Programmierern entdecken lassen. Bei manchen Bibliotheken ist es sogar erforderlich, das Programmgerüst eines anderen Programmierers zu verwenden, um nennenswerte Fortschritte zu erzielen. Entscheidend ist, sich nicht mehr als nötig auf den Code Dritter zu verlassen und schnell zu dem Punkt zu gelangen, an dem Sie den ursprünglich übernommenen Code selbst neu schreiben. Sie werden möglicherweise überrascht sein, wie viel sich aus dem Nachprogrammieren vorhandenen Codes lernen lässt. Stellen Sie sich vor, Ihnen begegnet im ursprünglichen Code der Aufruf einer Bibliotheksfunktion und Ihnen ist klar, dass die übergebenen Argumente ein bestimmtes Ergebnis liefern. Wenn Sie diesen Code aber selbst verwenden, um dieses Resultat zu reproduzieren, sind Sie gezwungen, die Dokumentation dieser Funktion zu konsultieren, um in Erfahrung zu bringen, was die verschiedenen Werte bedeuten und warum sie den jeweiligen Wert besitzen müssen, um den gewünschten Effekt zu erzielen.

8.3.4 Besuchen Sie einen Kurs

Als langjähriger Lehrer komme ich nicht umhin, diesen Abschnitt mit einigen Bemerkungen über Klassen zu beschließen – nicht im Sinn objektorientierter Programmierung, sondern im Sinn eines Kurses an einer Schule oder Universität. Für welches Gebiet der Programmierung Sie sich auch interessieren, irgendjemanden werden Sie schon finden, der dieses Thema unterrichtet, sei es in einem konventionellen Klassenzimmer oder auf einer Internetseite. Zwar bietet eine Klasse oder ein Kurs, insbesondere beim Thema Programmierung, eine ausgezeichnete Lernumgebung, aber wichtig ist das Lernen an sich. Wie kompetent oder begeistert der Lehrer auch sein mag, Sie sitzen doch immer vor Ihrem Computer, wenn Sie tatsächlich neue Programmierkenntnisse erwerben, und nicht im Klassenzimmer oder im Hörsaal. Wie ich im Buch ständig wiederholt habe, müssen Sie beim Programmieren Ideen in die Tat umsetzen, um diese wirklich und dauerhaft zu verinnerlichen.

Das soll nicht heißen, dass Kurse unnütz sind, ganz im Gegenteil. Bei der Programmierung sind manche Konzepte von Natur aus schwierig oder verwirrend, und wenn Ihr Lehrer das Talent besitzt, schwierige Sachverhalte einleuchtend erklären zu können, kann Ihnen das eine Menge Zeit und Frustration ersparen. Außerdem ermöglichen Kurse eine Bewertung des Wissensstands. Wenn Sie dann mit Ihrem Lehrer auch noch Glück haben, können Sie viel aus der Beurteilung Ihres Codes lernen und den Lernprozess dadurch weiter optimieren. Und schließlich erhalten Sie nach erfolgreicher Absolvierung des Kurses ein Zertifikat, das Ihnen Kenntnisse der unterrichteten Themen bescheinigt. (Wenn Sie das Pech haben, dass Ihr Lehrer lausig ist, können Sie sich damit vielleicht trösten.)

Seien Sie sich stets bewusst, dass Ihre Programmierausbildung in Ihrer eigenen Verantwortung liegt, auch wenn Sie einen Kurs besuchen. Ein Kurs gibt zwar einen Rahmen vor, um am Ende eine Bescheinigung zu erlangen, das schränkt Sie beim Lernen jedoch in keinster Weise ein. Nehmen Sie die im Kurs verbrachte Zeit als Chance wahr, über die im Lehrplan vorhergesehenen Ziele hinaus möglichst viel über das jeweilige Thema zu lernen.

8.4 Fazit

Ich denke gern an meine ersten Erfahrungen beim Programmieren zurück. Damals schrieb ich eine kurze, textbasierte Simulation eines Flippers – und nein, für mich ergibt das heute auch keinen Sinn mehr, aber seinerzeit tat es das wohl. Damals besaß ich keinen eigenen Computer (wer konnte 1976 schon einen Computer sein Eigen nennen?), aber im Büro meines Vaters gab es einen Fernschreiber, der im Wesentlichen ein riesiger Punktmatrixdrucker mit einer laut klappernden Tastatur war und über einen Akustikkoppler mit dem Großrechner der örtlichen Universität kommunizieren konnte. (Man musste den Telefonhörer abnehmen und die Telefonnummer mit der Hand wählen. Wenn dann ein elektronisches Kreischen erklang, legte man den Hörer in ein geeignetes Gestell, das mit dem Fernschreiber verbunden war.) Meine Flippersimulation war zwar äußerst primitiv und völlig unsinnig, aber in dem Moment, als das Programm funktionierte und der Computer meinen Befehlen Folge leistete, war es um mich geschehen.

Die an diesem denkwürdigen Tag erlebten Eindrücke – der Computer erschien mir wie ein unendlich hoher Berg von Legosteinen und Fischertechnik, der nur darauf wartet, dass ich alles nur Erdenkliche daraus zusammenbaue – befeuern noch heute meine Leidenschaft fürs Programmieren. Wenn meine Entwicklungsumgebung eine fehlerlose Kompilierung vermeldet und nur noch ein einziger Tastendruck zur Ausführung meines Programms nötig ist, bin ich in Erwartung von Erfolg oder Fehlschlag immer nervös und gespannt, das Resultat meiner Bemühungen zu begutachten. Dabei spielt es keine Rolle, ob es sich um einen einfachen Test handelt oder ich letzte Hand an ein bedeutendes Projekt anlege, ob ich eine wunderschöne Grafik berechne oder nur die Benutzerschnittstelle für eine Datenbankanwendung zusammenbaue.

Ich kann mir nur wünschen, dass es Ihnen beim Programmieren ähnlich ergeht. Selbst wenn Sie sich noch mit einigen der im Buch behandelten Themen abmühen, hoffe ich doch, dass Sie nun Folgendes erkennen: Wenn Sie das Programmieren so sehr fasziniert, dass Sie ihm immer treu bleiben möchten, gibt es keine Aufgaben, die Sie nicht lösen können. Sie müssen nur den Willen aufbringen, gewisse Anstrengungen zu leisten und die Sache richtig anzugehen. Alles andere erledigt sich mit der Zeit von selbst.

Denken Sie schon wie ein Programmierer? Wenn Sie die Übungen am Ende der Kapitel durchgeführt haben, sollte dies der Fall sein, und Sie dürfen Ihren Fähigkeiten zum Lösen von Problemstellungen vertrauen. Falls Sie nur einige wenige oder sogar keine der Übungen absolviert haben, möchte ich Ihnen einen Vorschlag unterbreiten. Sie haben es sicherlich geahnt: Lösen Sie die Übungsaufgaben. Wenn Sie diese in den vorhergehenden Kapiteln übersprungen haben, sollten Sie aber nicht mit den Übungen dieses Kapitels anfangen, sondern dort weitermachen, wo Sie aufgehört haben, und sich allmählich vorarbeiten. Wenn Sie kein Interesse an weiteren Übungen haben, weil Ihnen das Programmieren eigentlich keine Freude bereitet, kann ich Ihnen auch nicht helfen.

Sobald Sie anfangen, wie ein Programmierer zu denken, dürfen Sie auf Ihre Fähigkeiten stolz sein. Einen reinen Code könnte auch ein gut dressierter Specht gegen einen Baumstamm klopfen. Sie schreiben nicht einfach nur Code, Sie verwenden Code, um damit Probleme zu lösen. Dem nächsten Bewerbungsgespräch können Sie gelassen entgegensehen, denn was auch immer von Ihnen verlangt wird, Sie sind dafür gewappnet.

8.5 Übungen

Sie werden damit gerechnet haben, dass es eine letzte Runde Übungen gibt. Diesmal sind sie selbstredend schwieriger und ergebnisoffener als in den übrigen Kapiteln.

8.1

Schreiben Sie eine Version des schummelnden Galgenmännchen-Programms, die besser ist als meine.

8.2

Erweitern Sie Ihr Galgenmännchen-Programm um die Fähigkeit, dass der Benutzer die Rolle des ersten Spielers einnehmen kann. Der Benutzer kann nach wie vor die Länge des gesuchten Worts und die Anzahl der erlaubten Fehlversuche festlegen, aber Buchstaben erraten muss nun das Programm.

8.3

Schreiben Sie eine neue Version des Galgenmännchen-Programms in einer anderen Programmiersprache, die Sie nicht oder kaum kennen.

8.4

Erstellen Sie eine grafische Version des Galgenmännchen-Programms, das den Galgen und den Delinquenten während des Spiels anzeigt. Da Sie lernen möchten, wie ein Programmierer zu denken, nicht wie ein Künstler, brauchen Sie der Qualität der Darstellung keine weitere Beachtung zu schenken. Sie müssen jedoch echte Grafik verwenden, nicht nur ASCII-Zeichen, denn das wäre zu einfach. Sehen Sie sich 2D-Grafikbibliotheken für C++ an oder suchen Sie nach anderen Bibliotheken, die von Haus aus Grafikfähigkeiten mitbringen, wie z. B. Flash. Ein grafisches Galgenmännchen-Programm macht es wohl erforderlich, die Anzahl der erlaubten Fehlversuche zu begrenzen, aber es ist durchaus möglich, zumindest einen gewissen Wertebereich dafür anzubieten.

8.5

Entwerfen Sie eine eigene Problemstellung: Verwenden Sie die beim Galgenmännchen-Programm erworbenen Kenntnisse, um eine völlig andere Aufgabe zu lösen, bei der eine Liste mit Wörtern verarbeitet werden muss, wie etwa andere Spiele mit Wörtern, z. B. Scrabble, eine Rechtschreibprüfung oder was Ihnen sonst noch einfällt.

8.6

Entwerfen Sie eine eigene Problemstellung: Suchen Sie nach einer C++-Programmieraufgabe, die so umfangreich oder schwierig ist, dass Sie diese früher für unlösbar hielten, und lösen Sie sie.

8.7

Entwerfen Sie eine eigene Problemstellung: Suchen Sie nach einer Bibliothek oder API, die Sie noch nicht verwendet haben, an der Sie aber sehr interessiert sind. Erforschen Sie diese und schreiben Sie damit ein Programm. Wenn Sie an allgemeiner Programmierung interessiert sind, kommen hier Microsofts .NET-Bibliothek oder eine der quelloffenen Datenbankbibliotheken infrage. Falls Ihnen Grafikprogrammierung mehr zusagt, sollten Sie OpenGL oder DirectX in Erwägung ziehen. Und wenn Sie sich an der Programmierung eines Spiels versuchen möchten, sollten Sie sich eine Laufzeitumgebung für Spiele wie das quelloffene Ogre ansehen. Überlegen Sie sich, welche Art von Programmen Sie gerne schreiben würden, suchen Sie nach einer passenden Bibliothek und legen Sie los!

8.8

Entwerfen Sie eine eigene Problemstellung: Schreiben Sie ein Programm für eine Ihnen völlig unbekannte Plattform, wie z. B. für Smartphones, Tablets oder Webbrowser.