Programmkonstruktion
Programmkonstruktion
Skriptum zu
PROGRAMMKONSTRUKTION
185.A02 Grundlagen der Programmkonstruktion 183.592 Programmierpraxis im Wintersemester 2013/2014
Inhaltsverzeichnis
1 Maschinen und Programme 1.1 Ein Java-Programm . . . . . . . . . . . . . . . . . . 1.1.1 Simulierte Objekte . . . . . . . . . . . . . . 1.1.2 Programmablauf . . . . . . . . . . . . . . . 1.2 Binre Digitale Systeme . . . . . . . . . . . . . . . 1.2.1 Entstehung der Digitaltechnik . . . . . . . . 1.2.2 Binre Systeme . . . . . . . . . . . . . . . . 1.2.3 Rechnen mit Binrzahlen . . . . . . . . . . . 1.2.4 Logische Schaltungen . . . . . . . . . . . . . 1.3 Maschinen und Architekturen . . . . . . . . . . . . 1.3.1 Architektur blicher Computer . . . . . . . 1.3.2 Abstrakte Maschinen und Modelle . . . . . . 1.3.3 Objekte als Maschinen . . . . . . . . . . . . 1.3.4 Softwarearchitekturen . . . . . . . . . . . . 1.4 Formale Sprachen, bersetzer und Interpreter . . . 1.4.1 Syntax, Semantik und Pragmatik . . . . . . 1.4.2 Bestandteile eines Programms . . . . . . . . 1.4.3 Compiler und Interpreter . . . . . . . . . . . 1.4.4 bersetzung und Ausfhrung . . . . . . . . 1.5 Denkweisen . . . . . . . . . . . . . . . . . . . . . . 1.5.1 Sprachen, Gedanken und Modelle . . . . . . 1.5.2 Der Lambda-Kalkl . . . . . . . . . . . . . . 1.5.3 Eigenschaften des Lambda-Kalkls . . . . . 1.5.4 Zusicherungen und Korrektheit . . . . . . . 1.6 Softwareentwicklung . . . . . . . . . . . . . . . . . 1.6.1 Softwarelebenszyklus . . . . . . . . . . . . . 1.6.2 Ablauf und Werkzeuge der Programmierung 1.6.3 Softwarequalitt . . . . . . . . . . . . . . . . 1.6.4 Festlegung von Softwareeigenschaften . . . . 1.7 Programmieren lernen . . . . . . . . . . . . . . . . 1.7.1 Kontrollfragen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11 12 12 16 20 20 22 24 29 31 31 34 37 38 40 40 43 45 48 50 51 56 60 63 66 66 68 70 73 75 77
Inhaltsverzeichnis
2 Grundlegende Sprachkonzepte 2.1 Die Basis . . . . . . . . . . . . . . . . . . . . . . . . . 2.1.1 Vom Algorithmus zum Programm . . . . . . . . 2.1.2 Variablen und Zuweisungen . . . . . . . . . . . 2.1.3 Datentypen . . . . . . . . . . . . . . . . . . . . 2.2 Ausdrcke und Operatoren . . . . . . . . . . . . . . . . 2.2.1 Allgemeines . . . . . . . . . . . . . . . . . . . . 2.2.2 Operatoren in Java . . . . . . . . . . . . . . . . 2.2.3 Typumwandlungen und Literale . . . . . . . . . 2.3 Blcke und bedingte Anweisungen . . . . . . . . . . . . 2.3.1 Blcke . . . . . . . . . . . . . . . . . . . . . . . 2.3.2 Selektion mit if-else . . . . . . . . . . . . . . 2.3.3 Mehrfach-Selektion mit der switch-Anweisung 2.4 Funktionen . . . . . . . . . . . . . . . . . . . . . . . . 2.4.1 Methodendention anhand eines Beispiels . . . 2.4.2 Beispiel einer Aufrufsequenz . . . . . . . . . . . 2.4.3 Rekursive Methoden . . . . . . . . . . . . . . . 2.4.4 Zusicherungen . . . . . . . . . . . . . . . . . . . 2.5 Schleifen und Arrays . . . . . . . . . . . . . . . . . . . 2.5.1 while- und do-while-Schleifen . . . . . . . . 2.5.2 Arrays . . . . . . . . . . . . . . . . . . . . . . . 2.5.3 for-Schleifen . . . . . . . . . . . . . . . . . . . 2.5.4 Beispiel: Pascalsches Dreieck . . . . . . . . . . . 2.6 Zustnde . . . . . . . . . . . . . . . . . . . . . . . . . . 2.6.1 Seiteneekte . . . . . . . . . . . . . . . . . . . . 2.6.2 Funktionen und Prozeduren in Pascal . . . . . . 2.6.3 Methoden mit Seiteneekten in Java . . . . . . 2.6.4 Funktionaler und prozeduraler Programmierstil 2.7 Kommunikation mit der Auenwelt . . . . . . . . . . . 2.7.1 Die Methode main . . . . . . . . . . . . . . . . 2.7.2 Einlesen und Ausgeben . . . . . . . . . . . . . . 2.7.3 Transformatorische und reaktive Systeme . . . . 2.8 Java-Grundlagen lernen . . . . . . . . . . . . . . . . . 2.8.1 Kontrollfragen . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
81 81 81 86 91 95 95 100 109 115 115 118 120 122 123 127 130 133 135 137 138 142 144 149 149 150 154 157 158 158 160 163 165 166 171 171 171 175
3 Objektorientierte Konzepte 3.1 Das Objekt . . . . . . . . . . . . . . . . . . . . . . . . . . 3.1.1 Abstrakte Sichtweisen . . . . . . . . . . . . . . . . 3.1.2 Faktorisierung: Prozeduren und Objekte . . . . . .
Inhaltsverzeichnis
3.2
3.3
3.4
3.5
3.6
3.1.3 Datenabstraktion . . . . . . . . . . . . . . . . Die Klasse und ihre Instanzen . . . . . . . . . . . . . 3.2.1 Variablen und Methoden . . . . . . . . . . . . 3.2.2 Sichtbarkeit . . . . . . . . . . . . . . . . . . . 3.2.3 Identitt und Gleichheit . . . . . . . . . . . . 3.2.4 Kontext und Initialisierung . . . . . . . . . . . 3.2.5 Konstanten . . . . . . . . . . . . . . . . . . . Interfaces und dynamisches Binden . . . . . . . . . . 3.3.1 Interfaces zur Schnittstellenbeschreibung . . . 3.3.2 Dynamisches Binden . . . . . . . . . . . . . . 3.3.3 Spezialisierung und Ersetzbarkeit . . . . . . . Vererbung . . . . . . . . . . . . . . . . . . . . . . . . 3.4.1 Ableitung von Klassen . . . . . . . . . . . . . 3.4.2 Klassen versus Interfaces . . . . . . . . . . . . 3.4.3 Von Object abwrts . . . . . . . . . . . . . . . Quellcode als Kommunikationsmedium . . . . . . . . 3.5.1 Namen, Kommentare und Zusicherungen . . . 3.5.2 Faktorisierung, Zusammenhalt und Kopplung 3.5.3 Ersetzbarkeit und Verhalten . . . . . . . . . . Objektorientiert programmieren lernen . . . . . . . . 3.6.1 Kontrollfragen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
177 181 181 185 189 193 197 199 199 205 210 213 213 218 222 226 227 232 235 237 239 243 243 243 245 248 250 250 254 258 264 265 268 271 274 274 277 279
4 Daten, Algorithmen und Strategien 4.1 Begrisbestimmungen . . . . . . . . . . . . 4.1.1 Algorithmus . . . . . . . . . . . . . . 4.1.2 Datenstruktur . . . . . . . . . . . . . 4.1.3 Lsungsstrategie . . . . . . . . . . . 4.2 Rekursive Datenstrukturen und Methoden . 4.2.1 Verkettete Liste . . . . . . . . . . . . 4.2.2 Rekursion versus Iteration . . . . . . 4.2.3 Binrer Baum . . . . . . . . . . . . . 4.3 Algorithmische Kosten . . . . . . . . . . . . 4.3.1 Abschtzung algorithmischer Kosten 4.3.2 Kosten im Zusammenhang . . . . . . 4.3.3 Zufall und Wahrscheinlichkeit . . . . 4.4 Teile und Herrsche . . . . . . . . . . . . . . 4.4.1 Das Prinzip . . . . . . . . . . . . . . 4.4.2 Pragmatische Sichtweise . . . . . . . 4.4.3 Strukturelle hnlichkeiten . . . . . .
Inhaltsverzeichnis
4.5 Abstraktion und Generizitt . . . . . . . . . 4.5.1 Generische Datenstrukturen . . . . . 4.5.2 Gebundene Generizitt . . . . . . . . 4.5.3 Abstraktionen ber Datenstrukturen 4.5.4 Iteratoren . . . . . . . . . . . . . . . 4.6 Typische Lsungsstrategien . . . . . . . . . 4.6.1 Vorgefertigte Teile . . . . . . . . . . 4.6.2 Top-Down versus Bottom-Up . . . . 4.6.3 Schrittweise Verfeinerung . . . . . . . 4.7 Strukturen programmieren lernen . . . . . . 4.7.1 Kontrollfragen . . . . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
282 282 286 289 292 298 298 301 304 306 307 311 311 312 314 317 321 321 324 327 330 333 333 336 339 342 343 346 348 351 352 355 360 363 363 365 368 369
5 Qualittssicherung 5.1 Spezikationen . . . . . . . . . . . . . . . . . . . . . . 5.1.1 Anforderungsspezikation und Anwendungsflle 5.1.2 Design-by-Contract . . . . . . . . . . . . . . . . 5.1.3 Abstraktion und Intuition . . . . . . . . . . . . 5.2 Statisches Programmverstndnis . . . . . . . . . . . . . 5.2.1 Typen und Zusicherungen . . . . . . . . . . . . 5.2.2 Schleifeninvarianten . . . . . . . . . . . . . . . . 5.2.3 Termination . . . . . . . . . . . . . . . . . . . . 5.2.4 Beweise und deren Grenzen . . . . . . . . . . . 5.3 Testen . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.3.1 Auswirkungen auf Softwarequalitt . . . . . . . 5.3.2 Testmethoden . . . . . . . . . . . . . . . . . . . 5.3.3 Laufzeitmessungen . . . . . . . . . . . . . . . . 5.4 Nachvollziehen des Programmablaufs . . . . . . . . . . 5.4.1 Stack-Traces und Debug-Output . . . . . . . . . 5.4.2 Debugger . . . . . . . . . . . . . . . . . . . . . 5.4.3 Eingrenzung von Fehlern . . . . . . . . . . . . . 5.5 Ausnahmebehandlung . . . . . . . . . . . . . . . . . . 5.5.1 Abfangen von Ausnahmen . . . . . . . . . . . . 5.5.2 Umgang mit Ausnahmefllen . . . . . . . . . . 5.5.3 Aufrumen . . . . . . . . . . . . . . . . . . . . 5.6 Validierung . . . . . . . . . . . . . . . . . . . . . . . . 5.6.1 Validierung von Daten . . . . . . . . . . . . . . 5.6.2 Validierung von Programmen . . . . . . . . . . 5.7 Qualitt sichern lernen . . . . . . . . . . . . . . . . . . 5.7.1 Kontrollfragen . . . . . . . . . . . . . . . . . . .
Inhaltsverzeichnis
6 Vorsicht: Fallen! 6.1 Beschrnkte Ressourcen . . . . . . . . . . . . . . . 6.1.1 Speicherverwaltung . . . . . . . . . . . . . . 6.1.2 Dateien und Co . . . . . . . . . . . . . . . . 6.1.3 Antwortzeiten . . . . . . . . . . . . . . . . . 6.2 Grenzwerte . . . . . . . . . . . . . . . . . . . . . . 6.2.1 Umgang mit ganzen Zahlen . . . . . . . . . 6.2.2 Rundungsfehler . . . . . . . . . . . . . . . . 6.2.3 Null . . . . . . . . . . . . . . . . . . . . . . 6.2.4 O-by-One-Fehler und Puerberlufe . . . 6.3 Nebenlugkeit . . . . . . . . . . . . . . . . . . . . 6.3.1 Parallelitt und Nebenlugkeit . . . . . . . 6.3.2 Race-Conditions und Synchronisation . . . . 6.3.3 Gegenseitige Behinderung . . . . . . . . . . 6.4 Einfachheit und Flexibilitt . . . . . . . . . . . . . 6.4.1 Strukturierte Programmierung . . . . . . . . 6.4.2 Typische Fallen objektorientierter Sprachen 6.4.3 Spezielle Fallen in Java . . . . . . . . . . . . 6.5 Vertrauen und Kontrolle . . . . . . . . . . . . . . . 6.5.1 Defensive und oensive Programmierung . . 6.5.2 Programmierstil und Vertrauen . . . . . . . 6.5.3 Einheitliche Regeln . . . . . . . . . . . . . . 6.6 Mythen . . . . . . . . . . . . . . . . . . . . . . . . 6.6.1 Paradigmen und Mythen . . . . . . . . . . . 6.6.2 Mythen in Java . . . . . . . . . . . . . . . . 6.7 Fallen umgehen lernen . . . . . . . . . . . . . . . . 6.7.1 Kontrollfragen . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
373 373 373 377 383 387 387 391 396 400 403 404 408 412 415 415 419 422 426 426 429 432 433 435 440 444 444 449 449 450 452 454 456 458 459 461 463 466
7 Information und rohe Daten 7.1 Informationsbegri nach Shannon . . . . . . . . . . . 7.1.1 Informationsgehalt als statistische Eigenschaft 7.1.2 Entropie . . . . . . . . . . . . . . . . . . . . . 7.1.3 Codierung . . . . . . . . . . . . . . . . . . . . 7.1.4 Betrachtungsebenen der Information . . . . . 7.2 Redundanz und Information . . . . . . . . . . . . . . 7.2.1 Datenkompression . . . . . . . . . . . . . . . 7.2.2 Verschlsselung und Information . . . . . . . 7.2.3 Information in Programmen . . . . . . . . . . 7.3 Interne Darstellung primitiver Werte . . . . . . . . .
Inhaltsverzeichnis
7.4
7.3.1 Zahlen . . . . . . . . . . . . . . . . . . . . . . 7.3.2 Zeichen . . . . . . . . . . . . . . . . . . . . . 7.3.3 Abstrakte Werte . . . . . . . . . . . . . . . . Interna der JVM . . . . . . . . . . . . . . . . . . . . 7.4.1 Speicher der JVM . . . . . . . . . . . . . . . . 7.4.2 Interne Darstellung von Objekten und Klassen 7.4.3 Dynamisches Binden mittels Methodentabelle Information in Objekten . . . . . . . . . . . . . . . . 7.5.1 Datenabstraktion . . . . . . . . . . . . . . . . 7.5.2 Untertypbeziehungen . . . . . . . . . . . . . . Externe Darstellung . . . . . . . . . . . . . . . . . . . 7.6.1 Serialisierung . . . . . . . . . . . . . . . . . . 7.6.2 Lesbare externe Darstellung . . . . . . . . . . Information und Daten verstehen lernen . . . . . . . 7.7.1 Kontrollfragen . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . .
. . . . . . . . . . . . . . .
. . . . . . . . . . . . . . .
467 469 473 475 475 477 478 480 480 481 482 483 485 486 487
Vorwort
Das Modul Programmkonstruktion bildet die Basis der Programmierausbildung in den Bachelorstudien der Informatik und Wirtschaftsinformatik an der TU Wien. Dieses Modul umfasst zwei Lehrveranstaltungen: Grundlagen der Programmkonstruktion (PK) und Programmierpraxis (PP). PK gibt einen berblick ber die Programmierung im weitesten Sinn. Man erhlt einen ersten Einblick in zahlreiche Themen, von denen viele spter im Studium genauer behandelt werden. Die Themen werden zueinander in Beziehung gesetzt. Auf dieser Grundlage fllt das sptere tiefe Eintauchen leichter. Auerdem soll einer zu engen Sichtweise der Programmierung schon von Beginn des Studiums an entgegengewirkt werden. In PP werden praktische Programmierfhigkeiten entwickelt. Studierende sollen die Fhigkeit erwerben, Programmieraufgaben selbstndig anhand mehr oder weniger genauer Spezikationen so zu lsen, dass bestimmte Qualittskriterien eingehalten werden. Es wird dringend empfohlen, die beiden Lehrveranstaltungen ganz zu Beginn des Studiums zusammen zu absolvieren. In PK werden unter anderem Konzepte eingefhrt, die zeitlich abgestimmt in PP praktisch anzuwenden sind. Auch nicht unmittelbar vorausgesetztes Wissen aus PK kann die Qualitt der Lsungen praktischer Programmieraufgaben in PP verbessern. Umgekehrt helfen zuvor in PP erworbene Fhigkeiten beim Verstndnis komplexer Zusammenhnge in PK. Das vorliegende Skriptum deckt das Stogebiet von PK ab, ist also als Skriptum fr PK zu verstehen. Aber auch in PP wird von der Kenntnis groer Teile dieses Skriptums ausgegangen. Das Skriptum ist daher fr Studierende beider Lehrveranstaltungen vorgesehen. Das Skriptum ist zyklisch aufgebaut. Wichtige Themen werden frh angesprochen und spter tiefergehend erlutert, machmal sogar mehrfach. Diese Struktur soll Anfnger(inne)n entgegenkommen, macht das Skriptum aber kaum dafr geeignet, bestimmte Themen rasch nachzuschlagen. Den in PK gebrachten Sto kann man sich, bei entsprechendem Bemhen auch ohne Programmier-Vorkenntnisse, in der vorgesehenen Zeit von einem Semester aneignen. Die Entwicklung praktischer Fhigkeiten kann dagegen, je nach Veranlagung und Vorkenntnissen, deutlich lnger dauern. Daher darf man fr PP bis zu zwei Semestern brauchen. Trotz exibler Planungsmglichkeiten wird dazu geraten, gleich zu Studienbeginn in PP einzusteigen und einen Schwerpunkt darauf zu legen. Sonst geht es sich auch mit zwei Semestern mglicherweise nicht aus.
Viele Studienanfnger(innen) bringen schon Programmier-Wissen mit. Dennoch wird empfohlen, sich intensiv mit PK und PP auseinanderzusetzen. Am Anfang mgen die Themen als sehr einfach erscheinen, aber im Laufe der Zeit steigt die Komplexitt rasch an. Man bersieht leicht den Zeitpunkt, ab dem das Vorwissen alleine nicht mehr ausreicht. Das Modul Programmkonstruktion ist Teil der Studieneingangs- und Orientierungsphase (STEOP). Eine positive Absolvierung ist Voraussetzung fr die Teilnahme an Lehrveranstaltungen ab dem dritten Semester. Man kann dies als Hrde sehen, die bersprungen werden muss, bevor man Zugang zu den komplexeren Themengebieten bekommt. Neben der Hrde sollte man jedoch auch die Chancen sehen: Die Programmierausbildung hat einen hohen Stellenwert. Von Studierenden werden gute Programmierkenntnisse erwartet, es wird ihnen aber auch viel Untersttzung beim Lernen geboten. Das Ziel ist ein hohes Niveau an Wissen und Knnen. Die besten Leistungen lassen sich nur mit viel Einsatz und Enthusiasmus erzielen. Knftige Informatiker(innen) bringen in der Regel viel Begeisterung fr das Programmieren mit. Es ist ein gutes Gefhl, wenn man eigene Gedanken und Ideen in Software real werden lassen kann. Manchmal knnen uns Fehler in einem Programm oder Schwierigkeiten beim Verstehen komplexer Zusammenhnge jedoch auch fast zur Verzweiung treiben. Das gehrt genauso dazu wie die Freude darber, wenn am Ende dennoch alles funktioniert. Programmieren ist keineswegs eine so emotionslose Angelegenheit, wie man gelegentlich suggeriert bekommt. Nur wer selbst Programme schreibt, kann die Begeisterung dafr verstehen. Wie so oft steht am Anfang ein manchmal schwieriger Lernprozess mit vielen kleinen Erfolgen, aber auch so manchem Rckschlag. Wer genug Durchhaltevermgen aufbringt um den Lernprozess zu meistern und sich von der Begeisterung anstecken lsst, wird beim Programmieren sicher viel Freude erleben. Das ist die eigentliche Triebfeder, die zu einem hohen Niveau an Wissen und Knnen fhrt. Lassen Sie sich von der Begeisterung fr das Programmieren anstecken! Viel Freude und Erfolg beim Programmierenlernen! Franz Puntigam Michael Reiter
10
Dieses Programm (ohne die zur besseren Lesbarkeit vorangestellten Zeilennummern) brauchen wir nur mehr in der Datei [Link] zu speichern, mit dem Befehl javac [Link] zu bersetzen und mit dem Befehl java Hello auszufhren. Schon haben wir eine Maschine zum Leben erweckt und dazu gebracht, mit der Welt in Kontakt zu treten. Zahlreiche Schwierigkeiten zeigen sich erst auf den zweiten Blick: Ein typisches Programm reiht viele Tausend oder Millionen Anweisungen aneinander, und jeder noch so kleine Fehler kann schwerwiegende Folgen nach sich ziehen. Es ist nicht leicht, den berblick ber ein riesiges Netzwerk an Anweisungen zu bewahren oder herauszunden, welchen Zweck eine einzelne Anweisung darin hat. Zudem ndern sich die Wnsche an das Programm im Laufe der Zeit ebenso wie die Maschinen, auf denen das Programm laufen soll. Auch die Personen, die das Programm erstellen und weiterentwickeln, bleiben nicht immer dieselben. Nur mit viel Wissen, geschultem abstraktem Denkvermgen und durchdachten Vorgehensweisen ist es mglich, die groe Komplexitt zu meistern.
11
Wir wollen in PK und PP daher auch Zusammenhnge zwischen Maschinen, Programmen und den in die Entwicklung und Anwendung von Programmen involvierten Personen sowie ihrem Umfeld betrachten (PK), grundlegende Konzepte und Aussagen der Informatik im Bereich der Programmierung einfhren und deren Auswirkungen auf konkrete Programmiersprachen und Programmierstile veranschaulichen (PK), Techniken, Werkzeuge und Vorgehensweisen kennenlernen, die uns bei der Erstellung hochwertiger Programme untersttzen (PK+PP), durch Lsen von Programmieraufgaben das handwerkliche Geschick und abstrakte Denkvermgen weiterentwickeln (hauptschlich PP).
12
Listing 1.2: Kapsel, die eine Zahl und Vergleichsmethoden enthlt 1 import [Link]; 2 3 public class UnbekannteZahl { 4 // die Zahl ist nur innerhalb der Klasse bekannt 5 private int zahl; 6 7 // Initialisierung mit Zufallszahl zwischen 0 und (grenze - 1) 8 // Voraussetzung: grenze > 0 9 public UnbekannteZahl(int grenze) { 10 zahl = (new Random()).nextInt() % grenze; 11 if (zahl < 0) { 12 zahl = zahl + grenze; 13 } 14 } 15 16 // Ergebnis von "gleich(n)" ist true wenn zahl gleich n ist 17 public boolean gleich(int vergleichszahl) { 18 return (zahl == vergleichszahl); 19 } 20 21 // Ergebnis von "kleiner(n)" ist true wenn zahl kleiner n ist 22 public boolean kleiner(int vergleichszahl) { 23 return (zahl < vergleichszahl); 24 } 25 }
die aber innerhalb des Objekts sehr wohl bekannt ist. Man unterscheidet die Innen- von der Auenansicht. Vor einem Betrachter von auen sind viele Details versteckt. Im Programmcode ndet man die Wrter public und private. Alles was private ist, kann man nur innerhalb der Klasse sehen, whrend alles was public ist, berall im Programm sichtbar ist. Im Rumpf der berall sichtbaren Klasse, das ist alles was innerhalb der geschwungenen Klammern nach class UnbekannteZahl steht (Zeilen 424), werden folgende Programmteile deklariert bzw. deniert: in Zeile 5 eine nur in der Klasse sichtbare Variable namens zahl, in den Zeilen 9 bis 14 ein berall sichtbarer Konstruktor mit demselben Namen wie die Klasse, also UnbekannteZahl, in den Zeilen 17 bis 19 und 22 bis 24 zwei berall sichtbare Methoden namens gleich und kleiner.
13
Jeder Deklaration und Denition in dieser Klasse ist ein Kommentar vorangestellt, das ist beliebiger Text zwischen // und dem Ende einer Zeile. Kommentare helfen Menschen dabei, das Programm zu verstehen, werden aber vom Computer in der Regel nicht beachtet. Die Deklaration in Zeile 5 legt die Sichtbarkeit private, den Typ int und den Namen zahl einer Variablen fest. Eine Variable steht fr einen im Laufe der Zeit nderbaren (= variablen) Wert. Man sagt auch, sie enthlt oder hat einen Wert. Beispielsweise hat die Variable zu einem Zeitpunkt den Wert 87 und zu einem anderen Zeitpunkt den Wert 3. Der Wert selbst ist in der Deklaration nicht festgelegt; genau deswegen spricht man nur von einer Deklaration und keiner Denition. Der Typ legt die Art der Werte fest, welche die Variable haben kann. Die Variable zahl hat den Typ int eine Abkrzung fr das englischsprachige Wort Integer, also ganze Zahl auf Deutsch. Ein anderer mglicher Typ wre boolean, der den Booleschen bzw. logischen Werten true (wahr) und false (falsch) entspricht. Aber auch der Name einer Klasse wie UnbekannteZahl kann als Typ verwendet werden, sodass eine mit diesem Typ deklarierte Variable Objekte dieser Klasse enthalten kann. Generell spricht man von den Instanzen eines Typs wenn man alle Werte meint, die von diesem Typ sind. Ganze Zahlen sind demnach Instanzen von int, und true und false sind Instanzen von boolean. Die Methoden gleich und kleiner werden deniert, nicht nur deklariert, das heit, es werden alle Details festgelegt, und diese bleiben im Laufe der Zeit gleich. Die Methoden eines Objekts erlauben anderen Objekten, mit diesem Objekt in Kontakt zu treten. Man tritt mit einem Objekt in Kontakt, indem man ihm eine Nachricht schickt, und das Objekt beantwortet die Nachricht. Die Klasse beschreibt ber Methoden die Nachrichten, welche die Objekte der Klasse verstehen, sowie das Objektverhalten, also das, was die Objekte machen, wenn sie solche Nachrichten empfangen. Obwohl der Wert der Variablen zahl in anderen Klassen nicht sichtbar ist, kann ber die berall sichtbaren Methoden gleich und kleiner etwas ber diesen Wert herausgefunden werden. Sowohl gleich als auch kleiner liefert ein Ergebnis vom Typ boolean zurck, das heit, solche Nachrichten an ein Objekt von UnbekannteZahl werden mit true oder false beantwortet. Eine Nachricht gleich(3) fragt an, ob die unbekannte Zahl gleich drei ist, und kleiner(3) ob die unbekannte Zahl kleiner drei ist. Das funktioniert auch mit anderen Zahlen. Werte wie die Zahl 3, die als Teil einer Nachricht an ein Objekt geschickt werden, nennt man Argumente oder aktuelle Parameter der Nachricht. Neben
14
der Sichtbarkeit, dem Ergebnistyp und dem Namen enthlt jede Denition einer Methode eine Liste formaler Parameter-Deklarationen oder kurz Parameter-Deklarationen in runden Klammern und einen Rumpf in geschwungenen Klammern. Die Deklaration eines formalen Parameters hnelt der einer Variablen, jedoch fehlt die Sichtbarkeit. Eine Methode wird in einem Objekt ausgefhrt, wenn das Objekt eine gleichnamige Nachricht empfngt. Whrend dieser Ausfhrung enthalten die formalen Parameter die Werte, die der Nachricht als Argumente mitgegeben wurden. Anzahl und Typen der Argumente mssen der Anzahl und den Typen der formalen Parameter entsprechen. Bei der Ausfhrung einer Methode werden die Anweisungen im Rumpf der Methode ausgefhrt, eine nach der anderen von oben nach unten und von links nach rechts. Die Rmpfe der Methoden gleich und kleiner bestehen nur aus je einer returnAnweisung, die eine Antwort an den Sender einer Nachricht zurckgibt. Der zurckgegebene Boolesche Wert ergibt sich aus der Anwendung eines Vergleichsoperators (== bzw. <) auf die Werte der Variablen zahl und des formalen Parameters vergleichszahl, wobei == dann true liefert wenn die verglichenen Werte identisch sind, und < wenn der linke Operand eine kleinere Zahl ist als der rechte. Konstruktoren hneln Methoden, haben aber keine Ergebnistypen, und der Name ist immer gleich dem Klassennamen. Konstruktoren werden bentigt, um mit new erzeugte Objekte zu initialisieren, wobei die Variablen des Objekts ihre ersten Werte bekommen. Man sagt auch, die Variablen des Objekts werden initialisiert. Der formale Parameter grenze des Konstruktors von UnbekannteZahl legt fest, in welchem Bereich die Zahl eines neuen Objekts liegen soll grer oder gleich 0 und kleiner grenze. Entsprechend dem Kommentar soll der Wert von grenze grer 0 sein. Im Rumpf des Konstruktors stehen zwei Anweisungen. Die erste weist der Variablen zahl links vom Zuweisungsoperator = den Wert zu, der im Ausdruck rechts von = berechnet wird. Dabei wird mittels new Random() ein neues Objekt der Klasse Random erzeugt und ber den parameterlosen Konstruktor Random() initialisiert. Diese Klasse ist in der Bibliothek [Link] deniert und wird von dort mittels der Anweisung in der ersten Zeile in das Programm importiert, also zugreifbar gemacht. Das neue Objekt ist ein Zufallszahlengenerator. Wir schicken ihm die parameterlose Nachricht nextInt() und bekommen eine zufllig gewhlte Zahl zurck, deren Wertebereich aber noch nicht so eingeschrnkt ist, wie wir ihn haben wollen. Daher wenden wir den Operator % auf diese Zahl und den Wert von grenze an, der den Rest der Division der Zufallszahl
15
durch grenze berechnet. Wenn die Zufallszahl negativ war, ist auch der Divisionsrest negativ. Der an zahl zugewiesene Wert ist somit grer als grenze und kleiner als grenze. Die zweite Anweisung, eine bedingte Anweisung, sorgt dafr, dass der Wert von zahl nach Beendigung der Ausfhrung des Konstruktors nicht kleiner 0 sein kann. Nur wenn der aktuelle Wert von zahl kleiner 0 ist, das ist die Bedingung in der bedingten Anweisung, wird die Anweisung zahl = zahl + grenze im Rumpf der bedingten Anweisung ausgefhrt. Dabei wird ein neuer Wert an zahl zugewiesen, der um den Wert von grenze grer ist als der alte Wert. Wie gewnscht ist der Wert von zahl danach sicher grer oder gleich 0 und kleiner grenze. Die Variable zahl bildet zusammen mit dem Konstruktor und den Methoden von UnbekannteZahl eine untrennbare Einheit. Erst durch die Methoden wird die Variable sinnvoll verwendbar, und die Methoden brauchen die Variable um ihre Aufgaben zu erfllen. Variablen und Methoden sind quasi in eine gemeinsame Kapsel eingeschlossen. Die Eigenschaft eines Objekts, Variablen und Methoden zu einer Einheit zusammenzufhren, nennt man Datenkapselung. Zusammen mit Data-Hiding, dem Verstecken von Details durch private, spricht man von Datenabstraktion. Wir knnen Objekte als abstrakte Einheiten betrachten, ohne wissen zu mssen, wie sie genau funktionieren. Auf solche abstrakte Weise haben wir ein neues Objekt von Random verwendet: ber eine Nachricht erhalten wir eine Zufallszahl, ohne zu wissen, wie sie ermittelt wird. Wir werden sehen: Abstraktion spielt in der Informatik eine sehr groe Rolle. 1.1.2 Programmablauf Listing 1.3 zeigt den Programmcode der Klasse Zahlenraten, die eine unbekannte Zahl in einem kleinen Spiel verwendet. Diese Klasse ist nicht dafr gedacht, dass Objekte von ihr erzeugt werden. Daher enthlt der Rumpf weder Variablen-Deklarationen noch Konstruktor-Denitionen. Stattdessen enthlt die Klasse eine spezielle Methode namens main, in deren Denition das Wort static klarstellt, dass die Methode zur Klasse selbst, aber nicht zu Objekten der Klasse gehrt. Aufgrund des Ergebnistyps void darf man auf eine entsprechende Nachricht keine Antwort erwarten. Die Methode main, die bis auf den Rumpf in jedem Programm so aussehen muss wie hier, deniert den Startpunkt des Programms. Im Allgemeinen enthlt der formale Parameter args Argumente, die einem
16
Listing 1.3: Spiel zum Erraten der Zahl in einem Objekt von UnbekannteZahl 1 import [Link]; 2 3 public class Zahlenraten { 4 public static void main (String[] args) { 5 UnbekannteZahl zuErraten = new UnbekannteZahl(100); 6 Scanner sc = new Scanner([Link]); 7 [Link]("Errate eine Zahl zwischen 0 und 99:"); 8 while ([Link]()) { 9 if ([Link]()) { 10 int versuch = [Link](); 11 if (versuch < 0 || versuch > 99) { 12 [Link]("Nur Zahlen von 0 bis 99!"); 13 } 14 else if ([Link](versuch)) { 15 [Link]("Gratulation! Zahl erraten!"); 16 return; 17 } 18 else if ([Link](versuch)) { 19 [Link]("Gesuchte Zahl ist kleiner."); 20 } 21 else { 22 [Link]("Gesuchte Zahl ist grer."); 23 } 24 } 25 else { 26 [Link](); 27 [Link]("Das ist keine erlaubte Zahl!"); 28 } 29 [Link]("Neuer Versuch (Ende mit ^D):"); 30 } 31 [Link]("Zahl leider nicht erraten :-("); 32 } 33 }
Programmaufruf mitgegeben werden knnen. In diesem Programm brauchen wir keine solchen Argumente. Trotzdem muss der formale Parameter vorhanden sein, weil die Sprachdenition von Java das so vorsieht. Es ist klar, warum wir eine spezielle Methode brauchen: Wenn ein Programm gestartet wird, gibt es noch keine Objekte, sondern nur Klassen. Wir knnen zu Beginn also keinem Objekt eine Nachricht schicken um die Ausfhrung einer Methode zu starten. Diese spezielle Methode wird ausgefhrt, ohne vorher eine Nachricht an ein Objekt schicken zu mssen.
17
Variablen knnen auch im Rumpf einer Methode deklariert werden. Solche lokalen Variablen existieren nur whrend der Ausfhrung der Methode und sind nur in dem Bereich (innerhalb der geschwungenen Klammern) sichtbar, in dem sie deklariert sind; daher brauchen wir auch keine Sichtbarkeit anzugeben. Die Variable zuErraten wird gleich in der Deklaration in Zeile 5 mit einem neuen Objekt von UnbekannteZahl im Wertebereich von 0 bis 99 initialisiert. In Zeile 6 wird eine lokale Variable sc vom Typ Scanner, einer aus [Link] importierten Klasse, deklariert und mit einem neuen Objekt initialisiert. Objekte von Scanner untersttzen die Umwandlung von Eingaben ber Tastatur oder aus einer Datei in ein Datenformat, das wir innerhalb des Programms bentigen. Dem Konstruktor von Scanner bergeben wir als Argument den Wert der Variablen [Link]. Diese von unserem System vorgegebene Variable steht fr die Standard-Eingabe, die normalerweise mit einem Terminal verbunden ist, also mit den ber die Tastatur erfolgten Eingaben in ein Fenster am Bildschirm. Unser Objekt von Scanner erlaubt uns das Einlesen ber die Tastatur eingegebener Daten in das Programm. In Zeile 7 verwenden wir mit [Link] eine weitere vom System vorgegebene Variable, die fr die Standard-Ausgabe steht und normalerweise mit demselben Terminal verbunden ist. An den Wert dieser Variablen schicken wir die Nachricht println (Abkrzung fr Print Line ) um die Textzeile im Argument am Bildschirm auszugeben. Die Textzeile ist vom Typ String, das ist eine Zeichenkette, also eine Folge beliebiger Zeichen eingeschlossen zwischen " am Anfang und Ende. Der ausgegebene Text fordert Benutzer(innen) des Programms auf, eine Zahl einzutippen. Eine while-Schleife bildet den Hauptteil der Methode (Zeilen 8 bis 30). Der Rumpf der Schleife wird so lange iteriert, also wiederholt ausgefhrt, solange die Schleifenbedingung [Link]() erfllt ist, das heit, sooft hintereinander wie zu Beginn jeder Iteration (= Schleifendurchlauf ) auf die Nachricht hasNext() an sc die Antwort true zurckgeliefert wird. Die Ausfhrung der Methode hasNext wird so lange warten, bis eine nicht-leere Eingabe vorhanden ist, das heit im Wesentlichen, bis jemand ber die Tastatur etwas anderes als nur Leerzeichen in das Terminal eingegeben und die Eingabe mit Enter abgeschlossen hat, oder die StandardEingabe (beispielsweise durch Eingabe von Control-d) geschlossen wird. Die Antwort ist true wenn eine nicht-leere Eingabe vorhanden ist und false wenn die Standard-Eingabe geschlossen wurde. Im ersten Fall wird der Rumpf der Schleife ausgefhrt siehe unten. Im zweiten Fall wird der Schleifenrumpf nicht mehr durchlaufen, und die erste Anweisung nach der
18
Schleife in Zeile 31 informiert Benutzer(innen) durch eine Textausgabe darber, dass die gesuchte Zahl nicht gefunden wurde. Die Ausfhrung von main und damit des ganzen Programms endet daraufhin, da es keine weiteren Anweisungen mehr gibt. Der Schleifenrumpf beginnt in Zeile 9 mit einer bedingten Anweisung. Diese hat zwei Rmpfe, die wir Zweige nennen. Der erste Zweig ab Zeile 10 wird nur ausgefhrt wenn die Bedingung wahr ist, der zweite Zweig ab Zeile 26, else-Zweig genannt, nur wenn die Bedingung falsch ist. Auf die Nachricht hasNextInt() an sc in der Bedingung bekommen wir die Antwort true, wenn die nchste Eingabe von der wir schon wissen, dass sie existiert eine Zahl ist. Falls sie keine Zahl ist, kommen wir in den else-Zweig. Dort wird durch die Nachricht next() an sc die nchste Eingabe gelesen und gelscht, damit danach nur mehr die darauf folgenden Eingaben gesehen werden. Die Antwort auf diese Nachricht ignorieren wir, da wir damit nichts anfangen knnen. Stattdessen informieren wir Benutzer(innen) ber die falsche Eingabe. Falls die Eingabe eine Zahl ist, kommen wir in den ersten Zweig, wo die lokale Variable versuch mit der Zahl aus der Eingabe initialisiert wird. Auch die Nachricht nextInt() an sc1 lscht die Eingabe, damit danach die darauf folgenden Eingaben sichtbar sind. In diesem Zweig machen wir mehrere Fallunterscheidungen, das heit, abhngig von verschiedenen Bedingungen wird einer von mehreren mglichen Programmzweigen ausgefhrt. Falls die eingelesene Zahl nicht im erlaubten Wertebereich liegt, also versuch einen Wert kleiner 0 oder grer 99 hat (|| steht fr oder), informieren wir Benutzer(innen) darber. Andernfalls, falls wir die gesuchte Zahl gefunden haben, informieren wir Benutzer(innen) ber diese Tatsache und brechen das Programm ab. Normalerweise bricht die return-Anweisung nur die Ausfhrung der Methode ab und bestimmt die Antwort der Methode, aber main liefert (wegen void) keine Antwort zurck und ist die spezielle Methode, mit der die Programmausfhrung beginnt und auch endet. Wir haben noch zwei mgliche Flle: In einem Fall ist die zu erratende Zahl kleiner und im anderen grer als die eingegebene Zahl. Benutzer(innen) werden jeweils darber informiert. Das Programm wird mit der nchsten Anweisung nach
1
Sowohl in Random (siehe Klasse UnbekannteZahl) als auch in Scanner gibt es eine Methode namens nextInt. Diese beiden Methoden haben aber nichts miteinander zu tun. Gleiche Nachrichten an Objekte unterschiedlicher Typen knnen ganz unterschiedliche Eekte haben. Wir knnen anhand der Typen von Variablen leicht unterscheiden, ob die Nachricht nextInt() an ein Objekt von Random oder Scanner (oder einer anderen Klasse) gerichtet ist.
19
der bedingten Anweisung fortgesetzt. Egal welche Zweige der bedingten Anweisungen ausgefhrt wurden abgesehen von dem einen Fall, der zum Programmabbruch fhrt landen wir immer bei der letzten Anweisung im Schleifenrumpf. Hier werden Benutzer(innen) durch eine Textausgabe aufgefordert, einen neuen Versuch zu starten. Danach wird die Ausfhrung mit der nchsten berprfung der Schleifenbedingung und gegebenenfalls der nchsten Iteration fortgesetzt. Um das Programm auszufhren erstellen wir zunchst die Textdateien [Link] und [Link], welche den Code in den Listings 1.2 und 1.3 (ohne Zeilennummern) enthalten. Diesen Quellcode mssen wir in einen Zwischencode bersetzen, der dann durch einen Interpreter oder eine virtuelle Maschine ausgefhrt wird. Dazu brauchen wir ein Terminal-Fenster, ber das wir Befehle an den Computer eingeben. Der Befehl javac [Link] [Link] ruft den Compiler auf, der aus den Quellcodedateien die Zwischencodedateien [Link] und [Link] erzeugt. Den Interpreter und das Spiel starten wir durch java Zahlenraten. Vor allem Leser ohne oder mit nur wenig Programmiererfahrung sollten das Spiel tatschlich ausprobieren und seine Funktionsweise zu verstehen versuchen. Manche Zusammenhnge werden klarer, wenn man kleine nderungen vornimmt und schaut, wie sich diese auswirken. Falls das Programm nicht in allen Details verstndlich ist, so ist das jetzt noch kein Problem. Wir mssen wichtige Grundlagen und Konzepte der Programmierung erst schrittweise einfhren. Dabei ist es aber hilfreich, wenn man zumindest schon einmal versucht hat, ein Programm zu verstehen.
20
Industrialisierung dar. Jede Lochkarte aus Holz oder Karton bestimmt durch das Vorhanden- oder Nichtvorhandensein eines Loches an einer bestimmten Stelle die Stellung eines Fadens oberhalb oder unterhalb eines kreuzenden Fadens. Viele zu einer endlosen Schleife zusammengeheftete und nacheinander verwendete Lochkarten, je eine Karte pro kreuzendem Faden, erzeugen ein Muster im Sto. Durch Austausch der Lochkarten ist das Muster leicht nderbar, ohne den Mechanismus modizieren zu mssen. Auch moderne Websthle arbeiten nach demselben Prinzip. Diese Websthle nutzten wahrscheinlich zum ersten Mal die Digitaltechnik in industriellem Mastab. Dass Webmuster in digitaler Form also als diskrete Werte im Gegensatz zu analogen bzw. kontinuierlichen Daten aufgezeichnet wurden, liegt an der Verwendung der Daten: Es ist nur entscheidend, ob ein Faden oberhalb oder unterhalb eines ihn kreuzenden Fadens liegt, wie weit ober oder unter ihm ist egal. Die nchste Lochkarte kommt erst zum Einsatz, wenn ein Faden vollstndig durchgefdelt ist. Anders verhlt es sich mit Drehorgeln und Spieluhren, wo die Daten, hnlich wie beim Webstuhl, in Form von Lchern oder Stiften auf Kartonbndern, Blechtrommeln und vergleichbaren Datentrgern vorliegen: Die Lngen von sowie Abstnde zwischen Lchern oder Stiften sind von Bedeutung. Damit sind Pausen und die Lngen der Tne oder Strken der Anschlge auf analoge, nicht digitale Weise bestimmt. Seit Ende des 19. Jahrhunderts wird die Lochkartentechnik auch verbreitet fr die Steuerung damals noch rein mechanischer Rechenmaschinen und Registrierkassen eingesetzt. Bekannt wurde die Technik vor allem durch die US-amerikanische Volkszhlung 1890, die durch den Einsatz eines Systems von Lochkarten, Stanzmaschinen und Zhlmaschinen in nur einem Jahr abgeschlossen werden konnte. Jede Person wurde durch eine Lochkarte aus Karton identiziert, die personenbezogene Daten durch Lcher an bestimmten Stellen festhielt. Solche Lochkarten wurden weltweit bis etwa 1980 in der Verwaltung eingesetzt, dann aber durch zuverlssigere magnetische Datentrger (z.B. Magnetstreifenkarten) und in jngster Zeit mehr oder weniger intelligente Speicherchips (z.B. Chipkarten) ersetzt. Ein Trend ist trotz aller technologischen nderungen seit Beginn der automationsuntersttzten Datenverarbeitung gleich geblieben: Daten werden zunehmend in digitaler Form aufgezeichnet und fast ausschlielich in digitaler Form weiterverarbeitet. Wenn Daten auf natrliche Weise in analoger Form vorliegen, werden sie durch digitale Daten angenhert. Beispielsweise wird die Krpergre nur auf ganze Zentimeter genau angegeben, ein Fingerabdruck nur durch dessen wichtigste Merkmale auf nor-
21
mierte Weise beschrieben, und eine Schallwelle durch eine Folge von in gleichmig kurzen Abstnden abgetasteten ungefhren Amplitudenwerten dargestellt. Dabei entstehen immer Digitalisierungsfehler (beispielsweise nur eine Krpergre von 180 cm angegeben, obwohl tatschlich 180,3 cm gemessen wurden), die aber in Kauf genommen werden. Der Vorteil der Digitalisierung liegt auf der Hand: Digitale Daten knnen ohne weiteren Qualittsverlust leicht gespeichert und verarbeitet werden, gleichgltig ob auf Lochkarten, magnetischen Speichermedien oder Speicherchips. Bei der Speicherung und Verarbeitung analoger Daten tritt dagegen durch Einsse von auen (Temperatur, Feuchtigkeit, Magnetfelder, Ste, etc.) praktisch immer ein Qualittsverlust auf, das heit, die Daten werden unkontrollierbar verndert. Auerdem kann der Digitalisierungsfehler immer so klein wie ntig gehalten werden. Man knnte die Krpergre auch in Millimetern statt Zentimetern angeben, sodass der Messfehler vermutlich grer wre als der Digitalisierungsfehler. Vor gar nicht so langer Zeit bliche analoge Rechenschieber ndet man nur mehr im Museum. Viele Taschenrechner sind solchen Przisionsinstrumenten preislich und leistungsmig klar berlegen. Auch Bild- und Tonmaterial wird fast nur mehr digital aufgezeichnet. Enthusiasten schwren zwar immer noch auf die unvergleichliche Qualitt alter Schallplatten ohne Digitalisierungsrauschen, aber kaum jemand kann sich einen Plattenspieler leisten, bei dem der Qualittsverlust durch die analoge Verarbeitung nicht deutlich grer ist als der durch kleine Digitalisierungsfehler. 1.2.2 Binre Systeme Auch ein weiteres Prinzip ist seit dem ersten programmierbaren Webstuhl unverndert geblieben: Daten werden berwiegend in binrer Form dargestellt. Dabei wird nur zwischen zwei mglichen Werten unterschieden, die fr 0 und 1, Ja und Nein, Wahr und Falsch, Ein- und Ausgeschaltet, Faden kreuzt einen anderen unterhalb oder oberhalb (Webstuhl), etc. stehen. Speichermedien, Datenbertragungsleitungen und Maschinen verwenden verschiedene physikalische Ausprgungen zur Unterscheidung dieser Werte Lcher, nderungen der Magnetisierungsrichtung, elektrische Spannungen oder Strme ber einem Schwellenwert, Breite von hellen und dunklen Streifen in Barcodes, etc. Wie die Werte unterschieden werden, spielt keine Rolle. Wichtig sind hingegen einige vereinfachende mathematische Eigenschaften binrer Systeme. Vor allem ist es einfach, mit Wahrheitswerten (Wahr und Falsch) umzugehen, und darauf aufbauende
22
= = = =
0 1 2 3
= = = =
4 5 6 7
= = = =
8 9 10 11
= = = =
12 13 14 15
binre Logik-Systeme werden intensiv genutzt. Mit Binrzahlen knnen Maschinen einfacher rechnen als mit Dezimalzahlen oder in anderen Zahlensystemen. Man knnte statt binrer Systeme beispielsweise dreiwertige Systeme verwenden und zwischen Null, Eins und Zwei oder Wahr, Falsch und Unbekannt, etc. unterscheiden. Davon macht man kaum Gebrauch, da man die mathematische Einfachheit binrer Systeme verlieren wrde. Eine einstellige binre Zahl (also 0 oder 1) nennt man Bit. Mit einem Bit alleine fngt man nicht viel an, mit mehreren Bits zusammen aber schon. Man verwendet eine xe Anzahl von Bits als grundlegende Einheit fr die Darstellung von Daten, so wie jede Lochkarte eine xe Anzahl von Bits enthlt. Ein solches Wort (auch Maschinenwort oder Binrwort genannt) enthlt in modernen Computern hug 32 oder 64 Bits, aber es gibt auch kleinere mit nur 4, 8 oder 16 Bits und grere mit 256, 512 oder 1024 Bits. Obwohl Worte (oder Wrter) mit 2k Bits bevorzugt werden, kommen auch solche mit beispielsweise 12, 24 oder 28 Bits vor. In n Bits lassen sich 2n unterschiedliche Werte ausdrcken, beispielsweise 16 Werte in 4 Bits. Welchem Wert ein bestimmtes Bitmuster entspricht, ist jedoch nicht x vorgegeben, sondern hngt von der Codierung der Werte ab. Abbildung 1.4 zeigt zwei bliche Codierungen fr Zahlen in 4 Bits, wobei eine nur nicht-negative Zahlen und die andere (in Klammern, Zweierkompliment genannt) negative wie positive Zahlen darstellen kann. In Java bestimmt der Typ die Codierung. So legt der Typ int fest, dass Werte als Zweierkompliment mit 32 Bit codiert werden. Dabei bestimmt das am weitesten links stehende Bit das Vorzeichen, und einen Wert negiert man, indem man ihn um eins verkleinert und dann alle Bits umkehrt (also das Kompliment bildet). Beispielsweise erhlt man die Binrdarstellung 1011 von -5 als Kompliment der Binrdarstellung 0100 von 4. Das ganz links stehende Bit eines Wortes ist das Most-Signicant-Bit (MSB), das ganz rechts stehende das Least-Signicant-Bit (LSB). Diese
23
1 1 1 1
Kilobyte (kB oder KB) = 210 Megabyte (MB) = 220 Gigabyte (GB) = 230 Terabyte (TB) = 240
Byte = 1.024 Byte 103 Byte Byte = 1.048.576 Byte 106 Byte Byte = [Link] Byte 109 Byte Byte = [Link].776 Byte 1012 Byte
Begrie sind auch dann klar, wenn links und rechts von der Blickrichtung abhngen, beispielsweise auf Lochkarten oder Schaltplnen. Aus historischen Grnden nennt man je 8 nebeneinander liegende Bits in einem Wort ein Byte. Ein Wort mit 32 Bits enthlt also 4 Bytes. Frher wurde jedes Zeichen (Buchstabe, Zier, Sonderzeichen, etc.) in einem Byte dargestellt. Da sich die vielen lnderspezischen Zeichen in den 28 = 256 mglichen Werten eines Bytes nicht ausgehen, stellt man seltener benutzte Zeichen in mehreren Bytes dar. Manchmal nimmt man auch 16- oder 32Bit-Worte. Dessen ungeachtet ist es blich, die Gre von Daten, die in einem Block aneinandergereihter Worte abgelegt sind, in Bytes anzugeben. Fr Texte entspricht diese Angabe grob genhert der Anzahl der Zeichen. Byte ist eine praktische Grenangabe fr kleine Datenmengen. Fr groe verwenden wir eher Kilobyte, Megabyte, Gigabyte und so weiter, siehe Abbildung 1.5. Da diese Einheiten hauptschlich in binren Systemen vorkommen, deniert man sie ber Zweierpotenzen (statt wie sonst blich ber Zehnerpotenzen). So hat ein Kilobyte 210 Byte, ein Megabyte 210 Kilobyte und so weiter. Der Multiplikationsfaktor 210 = 1.024 kommt nahe genug an 103 = 1.000 heran, sodass zumindest die Grenordnung stimmt, wenn wir in Zehnerpotenzen statt in Zweierpotenzen denken. Die Einheit fr Information ist bit. Obwohl ein bit (klein geschrieben) viel mit einem Bit (gro geschrieben) gemeinsam hat, gibt es doch wichtige Unterschiede. Beispielsweise kommen Bits nur ganzzahlig vor, whrend 3,2 bit ein sinnvolles Ma ist siehe Kapitel 7. 1.2.3 Rechnen mit Binrzahlen Ein Vorteil des Rechnens mit Binrzahlen liegt darin, dass es nur wenige Mglichkeiten gibt, zwei einstellige Binrzahlen zu kombinieren. Das
24
00 0 00 0 00 0 01
01 0 01 1 01 1 10
10 0 10 1 10 1
11 1 11 1 11 0
Diese Operation ist ein AND, bei der das Ergebnis genau dann 1 ist wenn beide Argumente 1 sind, siehe Abbildung 1.6. Bei OR ist das Ergebnis genau dann 1 wenn mindestens ein Argument 1 ist, und bei XOR (exklusives Oder) wenn genau ein Argument 1 ist. NOT negiert ein Bit. Komplexere Operationen lassen sich auf diese einfachen Operationen zurckfhren. Manchmal haben Ergebnisse mehr Stellen als die Argumente. Betrachten wir die Addition zweier einstelliger Binrzahlen: 0 + 0 = 00 0 + 1 = 01 1 + 0 = 01 1 + 1 = 10
Das zustzliche Bit des Ergebnisses heit bertrag. Das eigentliche Additionsergebnis wird durch XOR gebildet, der bertrag durch AND. Wenn mehrstellige Zahlen addiert werden, ist der bertrag zum nchsthheren Bit zu addieren. Beispielsweise wird 11 + 01 = 100 nach demselben Schema, nach dem wir Dezimalzahlen addieren, so berechnet: Wir beginnen mit dem LSB und bekommen 1 + 1 = 10. Das letzte Bit des Ergebnisses ist daher 0. Dann addieren wir die nchsten Bits zu 1 + 0 = 01 und zu dieser Zahl den bertrag aus der Berechnung des vorigen Bits, also 01 + 1 = 10. Das sind die vorderen Stellen des Ergebnisses. Ein Schema zum Berechnen eines Ergebnisses nennt man Algorithmus. Multiplikationen mehrstelliger Binrzahlen knnen doppelt soviele Stellen haben wie die multiplizierten Zahlen, siehe Abbildung 1.7. Wir knnen dafr denselben Algorithmus verwenden wie fr Dezimalzahlen. Folgendes Programmstck soll inhaltliche Aussagen verdeutlichen und einen Vorgeschmack auf die Programmierung in Java geben. Lesern ohne Programmiererfahrung wird empfohlen, das Programmstck aufmerksam
25
durchzulesen und darin nach Anhaltspunkten zu suchen, die einen Sinn ergeben. Keine Sorge: Dieses Beispiel muss noch nicht im Detail verstanden werden. Es geht eher darum, ein Gespr fr das Aussehen von Programmen und den Klang der Beschreibungen zu entwickeln. Die Klasse BitsAndWords in Listing 1.8 beschreibt Methoden, die einfache logische Operationen auf einzelnen Bits darstellen, sowie Methoden fr die Addition und vorzeichenlose Multiplikation von Worten. Fr Bits verwenden wir Boolesche Werte. Alle Methoden sind public (also berall sichtbar) sowie static (direkt in der Klasse aufrufbar, ohne ein Objekt der Klasse zu bentigen). Die Methoden and, or, xor und not sind deswegen so einfach, weil Java fr diese Operationen die Operatoren &&, ||, ^ und ! besitzt, die direkt verwendet werden. Die Methoden add und mul sind etwas komplexer. Sie operieren nicht auf einzelnen Bits, sondern auf Worten, die aus mehreren Bits bestehen. Ein Wort wird durch ein Array von Booleschen Werten beschrieben, erkennbar an den eckigen Klammern []. Ein Array ist eine Aneinanderreihung mehrerer Werte gleichen Typs, wobei man ber einen Index also eine Zahl in eckigen Klammern angibt, der wievielte Wert in der Reihe gemeint ist. In BitsAndWords ist nirgends festgelegt, wie gro ein Array ist, also aus wie vielen Bits ein Wort besteht. Wenn a bzw. b eine Variable oder ein Parameter ist, der ein Array enthlt, kann man ber [Link] bzw. [Link] die Anzahl der Werte im Array feststellen. Hier wird davon ausgegangen, dass alle zu addierenden bzw. multiplizierenden Worte dieselbe Gre haben. Auch die Ergebnisse haben diese Gre. Addition und Multiplikation iterieren in for-Schleifen ber die einzelnen Bits, vom LSB zum MSB. Eine Iteration ist der einmalige Durchlauf durch den Rumpf einer Schleife. Wir haben je eine Iteration fr jedes Bit, das heit, wir iterieren ber die Bits. Eine for-Schleife erlaubt eine etwas kompaktere Darstellung des Codes als eine while-Schleife:
26
Listing 1.8: Bit-Operationen sowie Addition und Multiplikation von Worten 1 public class BitsAndWords { 2 public static boolean and(boolean a, boolean b) { 3 return (a && b); // a und b sind wahr 4 } 5 public static boolean or(boolean a, boolean b) { 6 return (a || b); // a oder b oder beide sind wahr 7 } 8 public static boolean xor(boolean a, boolean b) { 9 return (a ^ b); // entweder a oder b ist wahr 10 } 11 public static boolean not(boolean a) { 12 return (! a); // a ist nicht wahr 13 } 14 15 public static boolean[] add(boolean[] a, boolean[] b) { 16 // addiere zwei Worte a und b; [Link] == [Link] 17 boolean[] word = new boolean[[Link]]; 18 boolean carry = false; 19 for (int i = 0; i < [Link]; i++) { 20 boolean bit1 = xor(a[i], b[i]); // 1. Halbaddierer 21 boolean carry1 = and(a[i], b[i]); 22 boolean bit2 = xor(bit1, carry); // 2. Halbaddierer 23 boolean carry2 = and(bit1, carry); 24 word[i] = bit2; // Zusammenfassen der Teilergebnisse 25 carry = or(carry1, carry2); 26 } 27 return word; 28 } 29 public static boolean[] mul(boolean[] a, boolean[] b) { 30 // multipliziere zwei Worte a und b; [Link] == [Link] 31 boolean[] word = new boolean[[Link]]; 32 for (int i = 0; i < [Link]; i++) { 33 word[i] = false; 34 } 35 boolean[] shift = a; 36 for (int i = 0; i < [Link]; i++) { 37 if (b[i]) { 38 word = add(word, shift); 39 } 40 shift = add(shift, shift); 41 } 42 } 43 }
27
for (int i = 0; i < [Link]; i++) { ... } Zuerst wird ein Schleifenzhler i als lokale Variable vor der ersten Iteration deklariert und mit 0 initialisiert, dann kommt die Schleifenbedingung, die wie in einer while-Schleife vor jeder Iteration berprft wird, danach steht die Anweisung i++ gleichbedeutend mit i = i + 1, die am Ende jeder Iteration ausgefhrt wird und den Schleifenzhler erhht, und schlielich kommt noch der Schleifenrumpf, der in jeder Iteration (vor der Anweisung i++) ausgefhrt wird. In der Methode add wird zunchst eine lokale Variable word deklariert und mit einem neuen Array derselben Gre wie a initialisiert. Hier wird in der Schleife das Ergebnis-Wort, also die Summe von a und b Bit fr Bit abgelegt. Auerdem wird noch die lokale Variable carry deklariert und mit false initialisiert. Sie enthlt den bertrag von der Addition eines Bits zur Addition des nchsten Bits. Im Rumpf der Schleife wird das Ergebnis einer Bit-Addition schrittweise berechnet, wobei weitere lokale Variablen zum Speichern von Zwischenergebnissen verwendet werden. Es kommen zwei sogenannte Halbaddierer zum Einsatz, die jeweils das Ergebnis der Addition zweier Bits sowie den bertrag berechnen. Der erste Halbaddierer bildet die Summe der beiden einander entsprechenden Bits in a und b. Der zweite bildet dann die Summe aus dem Ergebnis des ersten Halbaddierers und dem bertragsbit carry. Das Ergebnisbit des zweiten Halbaddierers ist das Ergebnis der gesamten Addition und wird in word abgelegt, whrend der bertrag zum nchsten zu addierenden Bit durch OR aus den beiden bertragsbits der beiden Halbaddierer gebildet wird. Die Methode mul verwendet ebenso eine lokale Variable word zum Ablegen des Ergebnisses. Allerdings wird das Ergebniswort nicht Bit fr Bit berechnet, sondern durch wiederholte Additionen. Deshalb ist es notwendig, dass jeder Wert in word in der ersten Schleife mit false gefllt wird, sodass die erste Addition von der wohldenierten Zahl 0 in word ausgehen kann. Die lokale Variable shift enthlt zu Beginn dasselbe Array wie a. In der ersten Iteration der zweiten Schleife wird shift zu word addiert falls das LSB von b den Wert true hat, sonst nicht. Auerdem wird danach shift zu shift addiert (was einer Multiplikation mit 2 entspricht). In der nchsten Iteration wird also der doppelte Wert von a zu word addiert falls das zweite Bit von b true ist, in der darauolgenden Iteration der vierfache Wert falls das dritte Bit true ist und so weiter. Es wird also stets a 2n1 hinzuaddiert falls das n-te Bit von b true ist.
28
Eigentlich berechnet add nicht a + b, sondern (a + b) modulo [Link] , weil der letzte Wert von carry unbercksichtigt bleibt. In der Praxis werden solche Modulo-Rechnungen hug verwendet, beispielsweise fast berall in Java. Ebenso berechnet mul (a b) modulo [Link] , schneidet also alles ab, was ber die darstellbaren Bits hinausgeht. Wir haben angenommen, dass Worte nicht-negative Werte codieren. Die Multiplikation vorzeichenbehafteter Zahlen ist aufwendiger. Die Addition von Zahlen in Zweierkomplementdarstellung entspricht jedoch der von nicht-negativen Zahlen. Es ergeben sich jedoch eigenartige Eekte, wenn Ergebnisse mehr Binrstellen bentigen als vorhanden sind und daher abgeschnitten werden. Berechnungsergebnisse sind dann gnzlich falsch. Beispielsweise kann durch Abschneiden des Vorzeichenbits die Addition zweier positiver Zahlen ein negatives Ergebnis liefern. 1.2.4 Logische Schaltungen Heutige digitale Systeme sind fast ausschlielich als elektrische Schaltkreise auf der Basis von Halbleitern, also Transistoren und Dioden realisiert. Informatiker interessieren sich dafr, wie ein System aus vorgegebenen logischen Bausteinen zusammengesetzt ist. Wie diese Bausteine intern durch Halbleiter realisiert sind, ist fr ein Verstndnis des Systems nicht entscheidend. Die wichtigsten logischen Bausteine sind sogenannte Gatter, welche die oben beschriebenen einfachen Bit-Operationen ausfhren. Beispielsweise hat ein AND-Gatter zwei Eingnge und einen Ausgang und sorgt dafr, dass am Ausgang eine dem Wert 1 (oder true) entsprechende elektrische Spannung liegt wenn an beiden Eingngen ebenfalls eine 1 entsprechende Spannung liegt, sonst liegt am Ausgang eine 0 (oder false) entsprechende Spannung. OR-, XOR- und NOT-Gatter erfllen ihre Funktionen auf hnliche Weise. Oft sind mehrere logische Funktionen in ein Gatter integriert, beispielsweise AND und NOT in ein NANDGatter. Komplexere Schaltungen entstehen durch Kombination mehrerer Gatter, beispielsweise ein Halbaddierer durch die Kombination eines XORund eines AND-Gatters. Aus zwei NAND-Gattern lsst sich ein Flip-Flop bauen, das als Speicher fr ein Bit dient. Gatter reichen aus, um daraus ganze Computer zu bauen. Gatter brauchen einige Zeit, bevor der Ausgang den Spannungslevel erreicht, den er aufgrund der Spannungen an den Eingngen haben sollte. Solche Gatterlaufzeiten bewegen sich im Bereich von weniger als 0,1 ns
29
XOR
r
AND XOR
OR
C Y
bis ber 100 ns (Nanosekunden, 1 ns = 109 Sekunden). Wenn fr eine Berechnung mehrere Gatter hintereinander durchlaufen werden, addieren sich die Gatterlaufzeiten. Beispielsweise liegt im Volladdierer in Abbildung 1.9 (der aus zwei Halbaddierern und einem OR-Gatter besteht) das Ergebnisbit Y erst nach zwei und der neue bertrag C erst nach drei Gatterdurchlufen vor. Meist werden viele Berechnungen durchgefhrt, wobei Zwischenergebnisse in folgende Berechnungen einieen. Dabei ist es wichtig zu wissen, ab wann die Ergebnisse einer Berechnung vorliegen, die Ausgangssignale also stabil sind. Erst dann kann die nchste Berechnung gestartet werden. Meist gibt man einen xen Takt vor. Mit jedem Takt beginnt eine neue Berechnung. Die Taktfrequenz wird so gewhlt, dass alle Ergebnisse zu Beginn des nchsten Taktes sicher vorliegen. Es ist leicht vorstellbar, wie ein Webstuhl programmiert wird: Man entwirft ein Webmuster und stanzt hndisch fr bestimmte berkreuzungen der Fden an entsprechenden Stellen Lcher in die Lochkarten. Fast alle logischen Schaltungen werden dagegen durch Programme speziziert. Eine verbreitete Hardwarebeschreibungssprache ist VHDL, siehe Abbildung 1.10. Sowohl kleine als auch groe Schaltungen bis hin zu ganzen Prozessoren sind damit beschreibbar. Derart beschriebene Schaltungen knnen auf Computern simuliert werden um Fehler zu nden, und es ist mglich, die Beschreibungen in Hardware zu gieen, also daraus echte integrierte Schaltkreise (Chips) zu erzeugen. Eine nachtrgliche nderung einmal erzeugter integrierter Schaltkreise ist aber nicht mehr mglich. VHDL Programme beschreiben die Struktur der logischen Schaltungen, nicht den dynamischen Ablauf, also die Hintereinanderreihung einzelner Berechnungsschritte. Die bliche Programmierung von Computern konzentriert sich aber genau auf den dynamischen Ablauf und nimmt die Struktur der logischen Schaltungen als x vorgegeben an. Ab jetzt werden wir uns verstrkt mit dem dynamischen Ablauf beschftigen.
30
architecture RTL of ANDGATE is begin OUT1 <= IN1 and IN2; -- Berechnung des Ergebnisses end RTL;
31
ALU
I/O
, , , , , h h h l hh l l l l
cher anweisen Daten zu liefern, die ALU darauf eine Operation auszufhren, und wieder den Speicher die Ergebnisse abzulegen. Memory: Der Speicher enthlt die Daten und macht sie bei Bedarf den anderen Komponenten (vor allem der ALU) zugnglich. I/O Unit: Die Ein-/Ausgabeeinheit stellt die Verbindung zur Peripherie, also zu Gerten wie Festplatte, Tastatur, Grakkarte und Bildschirm und damit auch zur Auenwelt des Computers her. Alle Komponenten sind ber ein gemeinsames Bus-System, das ist ein System elektrischer Leiter, miteinander verbunden. Protokolle sorgen (hnlich den Verkehrsregeln fr eine Strae) dafr, dass der Nachrichtentransport mit mglichst wenig gegenseitiger Behinderung vonstatten geht. Abbildung 1.11 veranschaulicht diese Architektur. Der Speicher ist als Random-Access-Memory (RAM) aufgebaut, das heit, die im Speicher abgelegten Worte sind einzeln ansprechbar. Programme werden, wie auch alle anderen Daten, im Speicher abgelegt. An einer speziellen Adresse liegt der Befehlszeiger (Program-Counter = PC), der die Adresse des nchsten auszufhrenden Programmbefehls enthlt. Folgende Schritte werden endlos (bis zum Abschalten) wiederholt: Ein Befehl wird von der Adresse, auf die der PC zeigt, aus dem Speicher in die Control-Unit gelesen. Der PC wird erhht, sodass er auf den nchsten Befehl zeigt. Der Befehl wird von der Control-Unit interpretiert und ausgefhrt.
32
Die Ausfhrung eines Befehls kann den PC verndern, sodass der nchste Befehl nicht unmittelbar nach dem vorigen stehen muss. Eine solche nderung im Programmablauf nennt man Programmsprung oder kurz Sprung. ber Sprnge werden Schleifen und bedingte Verzweigungen realisiert. Die Harvard-Architektur ndert die Von Neumann-Architektur dahingehend ab, dass Befehle und Daten in getrennten Speichern liegen. Das hat Vorteile hinsichtlich der Ezienz und Betriebssicherheit, da Befehle nicht mehr (unabsichtlich) durch Daten berschrieben werden knnen. Aktuelle Computer verwenden sowohl Elemente der Von Neumann-Architektur als auch der Harvard-Architektur. Oft gibt es eine ganze Hierarchie von Speicherebenen, je hher die Ebene, desto ezienter und kleiner. Ganz oben stehen Register fr die gerade verarbeiteten Daten. Fr den PC und hnliches gibt es Spezialregister, die nur von speziellen Befehlen, z.B. Sprungbefehlen, verwendet werden. Zugrie auf Register sind in der Regel sehr ezient. Die nchsten Ebenen bilden Caches, meist getrennt in Daten- und Befehls-Caches. Diese enthalten eine Auswahl der Daten aus der untersten Ebene, dem Hauptspeicher, um raschere Zugrie darauf zu erlauben. Die am hugsten verwendeten Daten kommen automatisch in Caches, sodass wir uns beim Programmieren nicht darum kmmern mssen. Caches sind technische Details zur Leistungssteigerung, die in der Architektur der Maschine oft nicht sichtbar sind. Wir unterscheiden die Architektur von der Implementierung der Architektur. Letzteres ist eine konkret realisierte Maschine. Die Architektur beschreibt nur eine Grobstruktur und die ausfhrbaren Befehle, also alles, was man zum Programmieren ber die Maschine wissen muss. Alle Implementierungen einer Architektur verstehen die Programme, die fr diese Architektur geschrieben werden. Befehle werden durch Maschinenworte dargestellt, also durch Bitmuster, mit denen Maschinen einfach umgehen knnen, Menschen aber nicht. Fr die hardware-nahe Programmierung verwendet man daher AssemblerSprachen und nicht direkt Maschinen-Sprachen. Eine Assembler-Sprache stellt Befehle, Adressen und Daten durch fr Menschen besser lesbare Symbole dar, und ein Assembler bersetzt die Symbole in die eigentlichen Befehle. Abbildung 1.12 zeigt ein Assembler-Programm fr die Intel i386 Architektur unter dem Betriebssystem Linux. Das Programm ist nur mit viel Wissen ber die Maschine und das Betriebssystem verstndlich. Wegen dieser ungnstigen Eigenschaften meidet man die AssemblerProgrammierung weitgehend und programmiert in hheren Sprachen.
33
section .data ; Beginn des Abschnitts mit Daten hello db Hello World! ; der auszugebende Text length equ $ - hello ; aktuelle Position minus Beginn von hello
Ein Prozessor fasst die zentralen Teile eines Computers auf einem Chip zusammen, das sind ALU, Register, Control-Unit und (Teile der) Speicherverwaltung. Meist zhlen auch Caches dazu, aber nicht der Hauptspeicher. Viele Prozessoren haben mehrere Prozessor-Kerne, die weitgehend unabhngig voneinander arbeiten, so als ob wir mehrere Prozessoren htten. Damit sind mehrere Programme gleichzeitig ausfhrbar. Manchmal werden Programme in Teile aufgespaltet, die gleichzeitig (nebenlug oder parallel ) ausfhrbar sind und die Ressourcen mehrerer Prozessoren und Prozessor-Kerne nutzen knnen. Nebenluge Ausfhrungen innerhalb eines Programms heien Threads. 1.3.2 Abstrakte Maschinen und Modelle Genaugenommen bezieht sich der Begri Architektur, wie wir ihn in Abschnitt 1.3.1 eingefhrt haben, auf eine abstrakte Maschine, also eine abstrakte Beschreibung von Implementierungen. Im Falle der ComputerArchitektur ist der Abstraktionsgrad gering, das heit, die Architektur kommt nahe an eine reale Maschine heran. Hug verwenden und programmieren wir abstrakte Maschinen mit einem hheren Abstraktionsgrad. Die genaue Beziehung zwischen der abstrakten Maschine und deren Implementierung auf einer realen Maschine ist dabei kaum erkennbar. Die Java-Virtual-Machine (JVM) ist eine abstrakte Maschine. Auf den
34
ersten Blick unterscheiden sich JVM-Programme (siehe Abbildung 1.13) nur unwesentlich von Assembler-Programmen. Allerdings wurden die Befehle einer Hardware-Architektur dafr optimiert, dass sie ezient auf bestimmten realen Maschinen ausfhrbar sind, whrend die Befehle der JVM hinsichtlich der Unabhngigkeit von realen Maschinen optimiert wurden. Programme fr eine Hardware-Architektur laufen nur auf den Computern, die diese Architektur unter einem bestimmten Betriebssystem verwenden. Der hhere Abstraktionsgrad entkoppelt die JVM von Hardwareund Betriebssystem-Details, sodass JVM-Programme fast berall laufen knnen. Sie sind portabel, also von einer Maschine auf eine andere bertragbar. Damit knnen Computer in Netzwerken einfacher miteinander kooperieren. Allerdings hat der hhere Abstraktionsgrad auch einen Preis: Programme sind nicht so ezient ausfhrbar und in Betriebssysteme eingebunden als wenn sie fr ein bestimmtes System entwickelt worden wren. Die Programmierung abstrakter Maschinen ist einfacher da man Details der Hardware und des Betriebssystems nicht kennen muss. Aber die JVM ist fr die Programmierung bei weitem noch nicht abstrakt genug. Programme werden auf einer viel hheren Abstraktionsebene entwickelt. Abstrakte Maschinen auf sehr hohem Abstraktionsniveau werden als Berechnungsmodelle bezeichnet. Eines der einussreichsten Berechnungsmodelle ist der Lambda-Kalkl, der den Begri der mathematischen Funktion deniert und alles berechnen kann, was ber Funktionen ausdrckbar ist, siehe Abschnitte 1.5.2 und 1.5.3. Die Turing-Maschine ist ein anderes,
35
historisch bedeutendes Berechnungsmodell, das Berechnungen durch Beschreiben und Lesen eines gedanklich unendlich langen Bandes durchfhrt und nichts mit dem Lambda-Kalkl zu tun hat. Es spielt keine Rolle, ob eine solche Maschine tatschlich realisierbar ist. Vielmehr handelt es sich um Maschinen, die nur in unserer Vorstellung existieren und sich daher nicht an technische Grenzen halten mssen. Das gibt uns groe Freiheit. Wie wir aus Untersuchungen einer Vielzahl solcher Berechnungsmodelle wissen, ist die Freiheit aber nicht unendlich gro. Auch Berechnungsmodelle unterliegen gewissen Gesetzmigkeiten und Grenzen, genauso wie die Physik Gesetzmigkeiten und Grenzen in der Natur aufzeigt. Nicht alles, was wir gerne berechnen wrden, ist tatschlich berechenbar. Was berechenbar ist, hngt (ab einer bestimmten Mchtigkeit des Modells) nicht vom Berechnungsmodell ab. So knnen Lambda-Kalkl und Turing-Maschine wie viele weitere Maschinen (die Turing-vollstndigen Maschinen ) trotz ihrer Unterschiedlichkeit genau dasselbe berechnen. Das Erkennen von Gesetzmigkeiten in Berechnungsmodellen macht einen Groteil der Informatik aus. Daneben geht es natrlich auch darum, Aufgaben so zu lsen, dass wir Gesetzmigkeiten ausntzen und nicht an die Grenzen stoen. Hinter jeder Programmiersprache steckt ein Berechnungsmodell. Wir programmieren diese abstrakte Maschine, die wir dabei (oft unbewusst) im Kopf haben. Losgelst vom Berechnungsmodell ergibt eine Sprache keinen Sinn. Bevorzugt verwenden wir Berechnungsmodelle, die fr Menschen relativ einfach zu verstehen sind und die Intuition untersttzen. Das, was berechenbar ist, hngt ja nicht vom Berechnungsmodell ab, solange die Sprache bzw. das Modell Turing-vollstndig ist. Vom Modell hngt aber sehr wohl ab, wie einfach etwas ausdrckbar ist, wie viel Text dafr ntig ist, wie einfach Programme lesbar und nderbar sind, und so weiter. Viele Berechnungsmodelle sind mathematische Modelle als Grundlagen fr theoretische Analysen auf uerst abstraktem Niveau. Programmiersprachen haben einen etwas anderen Fokus: Manchmal mchte man auf einer hardware-nheren Ebene programmieren und Eigenschaften realer Maschinen direkt beeinussen knnen. Berechnungsmodelle hinter hheren Programmiersprachen gehen von mathematischen Modellen aus, erweitern diese aber oft um Konzepte auf hardware-nheren Ebenen. Es gibt Unterschiede im Abstraktionsgrad: Hardware-nahe hhere Programmiersprachen haben eine gute Untersttzung fr die Programmierung auf niedrigerem Abstraktionsniveau neben dem auf hohem Niveau, whrend reine Hochsprachen kaum auf niedrigerem Niveau programmierbar sind, aber die Programmierung auf hohem Niveau meist besser untersttzen.
36
1.3.3 Objekte als Maschinen In Abschnitt 1.1 haben wir gesehen, dass Objekte in Java-Programmen eine essentielle Rolle spielen. Software-Objekte simulieren Objekte der realen Welt, sodass wir Wissen aus der realen Welt direkt in die Software bertragen knnen. Wir knnen jedes einzelne Objekt aber auch als abstrakte Maschine sehen, welche die durch die Sprache vorgegebene Maschine erweitert. Dabei helfen folgende drei Eigenschaften von Objekten: Objektzustand: Die Werte aller Variablen eines Objekts ergeben zusammen den Objektzustand. Dieser ist durch Zuweisungen anderer Werte an die Variablen nderbar. Man kann den Objektzustand mit dem Zustand eines Computers (vor allem des Speicherinhalts) vergleichen, jedoch ist ein Objekt kleiner als der Computer und der Zustand eines Objekts damit leichter berschaubar als der des ganzen Computers. Objektverhalten: Das Objektverhalten beschreibt, wie das Objekt auf den Empfang von Nachrichten reagiert. Das entspricht dem Code in den Methodendenitionen. hnlich wie ein Computer einen Befehl nach dem anderen ausfhrt und dabei den Speicherzustand verndert, so wird auch im Objekt (entsprechend den empfangenen Nachrichten) eine Methode nach der anderen ausgefhrt und dabei der Objektzustand verndert. Objektidentitt: Jedes Objekt hat eine eindeutige Identitt, die das Objekt unverwechselbar macht. Diese ist ntig, um ein Objekt von anderen hnlichen Objekten zu unterscheiden (auch wenn sich der Objektzustand verndert) und Nachrichten an ganz bestimmte Objekte senden zu knnen. Computer verwenden hug Internetadressen zur eindeutigen Identizierung und zum Senden von Nachrichten. Hinter allen Objekten einer Klasse steckt dieselbe abstrakte Maschine. Wenn wir eine Klasse entwickeln, denken wir oft an diese abstrakte Maschine. Durch diese Denkweise knnen wir uns auf die wesentlichen Aspekte eines ganz bestimmten kleinen Teils der Software konzentrieren. Wir vermeiden damit, dass wir stets die viel zahlreicheren und undurchschaubareren Abhngigkeiten zwischen den Zustnden des ganzen Programms im Kopf haben mssen. Beispielsweise beschreibt die Klasse UnbekannteZahl in Listing 1.2 eine abstrakte Maschine, die fr Leser mit Programmiererfahrung durchaus verstndlich ist. Diese Klasse ist aber kein vollstndiges Programm, weil wesentliche Teile fehlen. Erst die Klasse Zahlenraten
37
in Listing 1.3 stellt ber Ein- und Ausgaben eine Verbindung zwischen einem Objekt von UnbekannteZahl und der Auenwelt her. Man knnte meinen, UnbekannteZahl sei gar nicht ntig, weil die zu erratende Zahl gleich als Instanz von int in Zahlenraten dargestellt werden knnte. Eine solche Sichtweise ist verstndlich. Allerdings wrde man dabei viel verlieren: UnbekannteZahl wurde als in sich logisch konsistente Einheit unabhngig von Zahlenraten entwickelt und kann auch in anderen Programmen verwendet werden. Jede der beiden Klassen ist einfacher verstndlich als eine kombinierte Klasse. Einfachheit und Verstndlichkeit sind in der Softwareentwicklung sehr wichtig. Um ein Objekt zu verwenden, brauchen wir nur dessen Schnittstelle verstehen. Die Schnittstelle beschreibt die Nachrichten, die vom Objekt verstanden werden. Die Implementierung des Objekts bzw. die Implementierungen der Methoden der entsprechenden Klasse, also den Code, der beim Empfang von Nachrichten ausgefhrt wird, brauchen wir dagegen nicht kennen. Wir mssen beispielsweise nur wissen, dass eine Nachricht gleich(3) an ein Objekt von UnbekannteZahl genau dann true als Antwort liefert, wenn die gekapselte Zahl gleich 3 ist. Dagegen spielt es keine Rolle, wie die gekapselte Zahl intern dargestellt ist und wie der Vergleich funktioniert. Wir knnen die Implementierung so verndern, dass die Schnittstelle und damit die Verwendungsmglichkeiten gleich bleiben. 1.3.4 Softwarearchitekturen Der Begri Architektur bezieht sich nicht nur auf die Baukunst oder Hardware, sondern auch auf Software. Unter einer Softwarearchitektur verstehen wir eine Beschreibung der wichtigsten Teile der Software und der Beziehungen zwischen diesen Teilen. Man bezeichnet diese Teile als Komponenten oder Module. Auch Klassen und Objekte sind Teile von Architekturen. Komponenten haben viele Eigenschaften von Objekten, knnen aber deutlich grer sein. Eine wichtige Rolle kommt ihren Schnittstellen zu, die bestimmen, wie die Komponenten zusammenspielen und miteinander kombinierbar sind. In anderen Bereichen der Technik wie im Maschinenbau, in der Elektrotechnik und im Bauwesen ist eine auf Komponenten ausgelegte Entwicklung schon seit langem nicht mehr wegzudenken. Beispielsweise besteht ein Auto aus Komponenten wie Motor, Getriebe und Radaufhngung. Eine kaputte Komponente ist gegen eine neue austauschbar. Komponenten knnen unabhngig voneinander entwickelt werden, solange die Schnitt-
38
stellen zusammenpassen. Auf das Auto bezogen ist das der Fall, wenn Schrauben, Bohrungen und Flansche an den richtigen Stellen sitzen und die bertragenen Krfte innerhalb der erwarteten Grenzen liegen. Ganz unterschiedliche Experten knnen sich um die Entwicklung der einzelnen Komponenten und den Zusammenbau des Ganzen kmmern. Ein Motorenbauer wei nur wenig ber Radaufhngungen, der Experte fr Radaufhngungen nur wenig ber Motoren, und der Autobauer kennt alle Komponenten oberchlich, hat sie aber nicht selbst entwickelt. So betrachtet jeder nur seine Maschine auf dem jeweils notwendigen Detailliertheitsgrad. In der Softwareentwicklung ist es hnlich. Hug konstruieren wir Software, indem wir (wie ein Autobauer) fertige Komponenten miteinander kombinieren. Oft erstellen wir (wie ein Motorenbauer) eine Komponente, die von anderen genutzt wird. Dabei protieren wir von unserem Spezialwissen in einem engen Bereich und achten darauf, dass die Schnittstellen unserer Komponente mit denen anderer zusammenpassen. Anders als Komponenten am Auto geht Software nicht durch Abnutzung kaputt, aber auch Software altert: Erwartungen und Einsatzbereiche ndern sich, und daher mssen auch Softwarekomponenten gelegentlich erneuert werden. Es gibt vielfltige Gestaltungsmglichkeiten fr Softwarearchitekturen. Beispielsweise hat sich in vielen Bereichen eine aus Schichten aufgebaute Architektur bewhrt, die wie die Schalen einer Zwiebel bereinander liegen. Die Komponenten einer Schicht haben nur Schnittstellen zu Komponenten der direkt darunter bzw. darber liegenden Schicht. Das entkoppelt die Komponenten voneinander und macht sie leichter austauschbar, wirkt sich aber negativ auf die Laufzeitezienz aus. Zahlreiche neuere Anwendungsprogramme sind in drei Schichten aufgebaut: Die oberste Schicht dient der Prsentation und Eingabe von Daten und wird oft durch einen Web-Browser realisiert. Die unterste Schicht verwaltet die Daten meist in einer Datenbank. Die mittlere Schicht enthlt die eigentliche Anwendungslogik, verknpft Eingaben aus der obersten Schicht mit Daten aus der untersten und sorgt dafr, dass alle Daten in sich konsistent und geschtzt bleiben. Klare Trennungen erlauben die Ausfhrung unterschiedlicher Schichten auf unterschiedlichen Rechnern. So spiegelt die Softwarearchitektur manchmal auch die Hardwarearchitektur wider. Bei ausreichend detaillierter Betrachtung kann jede Komponente wiederum aus Komponenten in mehreren Schichten aufgebaut sein. Softwarearchitekturen sorgen durch Abstraktion ber Details dafr, dass die Gesamtstruktur eines groen Systems verstndlich bleibt. Wir knnen das System auf jedem Detailliertheitsgrad betrachten, und auf jeder Betrach-
39
tungsebene sehen wir eine andere abstrakte Maschine, die unser Denken auf signikante Weise beeinusst. Gerade wegen dieses starken Einusses auf unser Denken ist die Softwarearchitektur so wichtig: Eine gute Architektur macht vieles einfacher, eine schlechte lsst sich durch noch so ausgefeilte Programmiertechniken kaum in den Gri bekommen.
40
1.4 Formale Sprachen, bersetzer und Interpreter Statement = | | | | | | { {Statement} } if ( Expression ) Statement [else Statement] for ( ForInit ; [Expression] ; [Expression] ) Statement while ( Expression ) Statement return [Expression] ; [Expression] ; ...
Abbildung 1.14: Ausschnitt aus der Grammatik von Java (in EBNF)
Ablauf eines Programms und sind oft viel komplexer als syntaktische Regeln. Im Gegensatz zu natrlichen Sprachen wird fr formale Sprachen viel Aufwand betrieben um die Semantik genau zu beschreiben. Pragmatik: Die Pragmatik untersucht das Verhltnis der Sprache zum Sprecher und zum Angesprochenen. Beispielsweise macht es in manchen Situationen einen groen Unterschied, ob man jemanden mit Sie oder Du anspricht, auch wenn die Stze die gleiche Semantik haben. Computer und formale Systeme nehmen solche Unterschiede nicht wahr. Fr die Programmierung ist die Pragmatik dennoch wichtig: Man versteht darunter alle praktischen Aspekte der Sprachverwendung, beispielsweise den Einsatz bestimmter Sprachelemente zur Verbesserung der Lesbarkeit von Programmen. Die Syntax einer Programmiersprache wird in dessen Grammatik festgelegt. Abbildung 1.14 zeigt einen kleinen, vereinfachten Ausschnitt aus der Grammatik von Java, genauer gesagt einer Anweisung (englisch Statement ). Wir verwenden die BNF oder EBNF ((Extended) Backus-NaurForm) als Meta-Sprache, eine Sprache ber der Sprache, zur Festlegung der Grammatik. Wrter, die als sogenannte Schlsselwrter im Programm genau gleich vorkommen wie in der Grammatik, sind fett geschrieben. Manchmal kennzeichnet man Schlsselwrter auch durch Hochkommata. Schlsselwrter drfen in keiner anderen Bedeutung, z.B. als Variablennamen, vorkommen. Der senkrechte Strich trennt Alternativen voneinander, A | B bedeutet also, dass das beschriebene Sprachelement so wie A oder so wie B aussieht. In nicht fett geschriebenen geschwungenen Klammern vorkommende Elemente knnen beliebig oft wiederholt oder gar nicht vorkommen, {{A}} steht also fr {} oder {A} oder {A A} und so weiter. Elemen-
41
te in eckigen Klammern sind optional, sie knnen vorkommen, mssen aber nicht; [A]; steht also fr A; oder nur ;. Sogenannter White-Space bestehend aus Leerzeichen, Tabulatorzeichen und Zeilenumbrchen bleibt in dieser Syntaxbeschreibung unbercksichtigt, abgesehen davon, dass White-Space nebeneinander stehende Wrter voneinander trennt; intzahl ist nur ein Wort, whrend int zahl zwei ganz andere Wrter sind. Abbildung 1.15 veranschaulicht die Grammatikregel aus Abbildung 1.14 auf grasche Weise. Ein Programmteil ist genau dann ein Statement, wenn es entlang der Pfeile einen entsprechenden Pfad vom Eingang auf der linken Seite zum Ausgang auf der rechten Seite gibt. Inhalte von Kreisen und Ovalen sind Terminalsymbole wie z.B. Schlsselwrter, Klammern, etc. Die Kstchen stehen fr entsprechend benannte Syntaxregeln. Wie wir an der Grammatikregel im Beispiel erkennen knnen, ist sowohl if(zahl<0){zahl=zahl+grenze;} als auch (ohne geschwungene Klammern) if(zahl<0)zahl=zahl+grenze; eine Anweisung vorausgesetzt, dass zahl<0 und zahl=zahl+grenze Ausdrcke, englisch Expressions, sind. Die Bedeutungen dieser Anweisungen gehen aus der Grammatik nicht hervor. Mit viel Aufwand oder Erfahrung knnen wir aus der umfangreichen Beschreibung von Java2 ableiten, dass die2
Siehe [Link]
42
se beiden Anweisungen dieselbe Bedeutung, also dieselbe Semantik haben. Fr die Ausfhrung des Programms ist es in diesem Fall egal, ob wir die geschwungenen Klammern hinschreiben oder nicht. Aber hinsichtlich der Pragmatik gibt es Unterschiede: Die Klammern knnen wir nur weglassen, wenn im Rumpf der bedingten Anweisung nur eine Anweisung steht, nicht bei mehreren. Aufgrund praktischer Erfahrungen schreiben wir die Klammern hin, da es dadurch spter einfacher und weniger fehleranfllig ist, den Rumpf um weitere Anweisungen zu ergnzen. Auerdem werden wir die Anweisung eher in dieser Form hinschreiben: if (zahl < 0) { zahl = zahl + grenze; } Der zustzliche White-Space erhht die Lesbarkeit. Die tiefere Einrckung des Rumpfes lsst die Struktur auf den ersten Blick erkennen. Gewisse Kenntnisse der Syntax und Semantik einer Programmiersprache sind die Grundvoraussetzung um berhaupt Programme schreiben zu knnen. Der Einsatz pragmatischen Wissens macht dagegen den Unterschied zwischen guten und nicht so guten Programmen aus. Oft haben Programmieranfnger sehr viel Wissen ber die Syntax und Semantik einer Sprache, knnen dieses Wissen beim Programmieren aber kaum umsetzen. Ihnen fehlt noch bestimmtes pragmatisches Wissen, das sich erst im Laufe der Jahre mit der Programmiererfahrung langsam entwickelt. 1.4.2 Bestandteile eines Programms Programme und Programmteile, egal in welcher Programmiersprache, setzen sich aus Beschreibungen folgender Aspekte zusammen: Datenstrukturen: Wir legen Daten in Variablen ab und beschreiben die Daten durch die Namen und Typen der Variablen. Daten stehen in Beziehungen zueinander und bilden dabei Datenstrukturen. Beispielsweise sind in Listing 1.8 die Bits eines Wortes zu einem Array, das ist eine bestimmte Datenstruktur, zusammengefasst. Programmiersprachen geben meist einfache Datenstrukturen vor und erlauben uns, daraus beliebig komplexe Datenstrukturen aufzubauen. Algorithmen: Ein Algorithmus ist eine aus endlich vielen Schritten bestehende eindeutige Handlungsvorschrift zur Lsung eines Problems, quasi ein Kochrezept, nach dem sogar eine Maschine ohne jegliche
43
Intelligenz eine Lsung zustandebringt. Unter einem Problem (im mathematischen Sinn) verstehen wir dabei eine Aufgabe, die wir lsen sollen. Die Lsung selbst besteht aus Daten. In Java beschreiben wir Algorithmen durch Methoden und Konstruktoren. Programmorganisation: Es muss mglich sein, Programme in voneinander mglichst unabhngige Teile (Klassen, Module und Komponenten) zu zerlegen. Andernfalls wre die Komplexitt grerer Programme nicht beherrschbar. Im Programm mssen Zusammenhnge zwischen den einzelnen Teilen beschrieben sein. In Java werden Variablen und Methoden zu Objekten zusammengefasst, diese wiederum zu Klassen, und Klassen im selben Ordner bilden Pakete. Umgebung: Die Umgebung einer Stelle im Programm bestimmt die Namen, die an dieser Stelle sichtbar sind. Beispielsweise sind in einer Methode die lokalen Variablen sowie die in der Klasse deklarierten bzw. denierten Variablen und Methoden sichtbar. Wir sehen von einer Java-Klasse aus andere Klassen, die im selben Ordner stehen. Damit wirkt sich auch die Programmorganisation auf die sichtbaren Namen aus. Mittels import-Deklarationen wie in den Listings 1.2 und 1.3 machen wir Klassen, die woanders deniert sind, sichtbar und beeinussen damit die Umgebung und Programmorganisation. Datenstrukturen, Algorithmen, etc. sind groteils statisch deniert, das heit, sie werden in Programmen x festgelegt und bleiben whrend der Programmausfhrung (also zur Laufzeit ) unverndert. Ausfhrungen der Algorithmen zur Laufzeit ndern die Daten in den Datenstrukturen jedoch dynamisch. Um ein Programm zu verstehen, mssen wir es sowohl auf der statischen als auch dynamischen Ebene verstehen. Die Grenze zwischen dem statischen und dynamischen Teil hngt von der Sprache und vom Programmierstil (das ist die Art und Weise des Programmierens) ab. Java ist eine eher statische Sprache, das heit, man versucht mglichst viel in den statischen Strukturen abzubilden, sodass man ein Programm alleine aus der statischen Betrachtung schon recht gut kennt. Dynamische Sprachen wie beispielsweise Ruby halten den statischen Anteil dagegen klein, sodass man zur Laufzeit viele Freiheiten hat, aber das Programm auf der statischen Ebene nicht immer so einfach verstehen kann. Obwohl es schwieriger ist als in Ruby, kann man auch in Java recht dynamisch programmieren und vieles zur Laufzeit hin verschieben. Dagegen kann man auch in Ruby einen eher statischen Programmierstil
44
pegen, indem man auf die Freiheiten der Sprache verzichtet. In statischen Sprachen spielen deklarierte Typen eine groe Rolle. Die Werte von Variablen, formalen Parametern und Methodenergebnissen werden in Deklarationen und Denitionen durch Typen statisch sofort sichtbar eingeschrnkt, was oft einen guten Anhaltspunkt fr ein Verstndnis des Zwecks dieser Werte ergibt. Das erhht die Lesbarkeit der Programme. Weiters ermglichen diese Typen statische Typberprfungen, die bestimmte Fehler im Programm ausschlieen, indem sie zusichern, dass Operatoren und Operanden immer miteinander kompatibel sind. Beispielsweise ist die Zuweisung zahl=zahl+grenze sinnvoll und korrekt, wenn zahl und grenze beide vom Typ int sind, aber nicht, wenn beide Variablen vom Typ boolean sind, oder eine vom Typ int und die andere vom Typ boolean. In dynamischen Sprachen gibt es keine deklarierten Typen; das erspart Zeit beim Schreiben. Allerdings sind inkompatible (also nicht zusammenpassende) Typen erst zur Laufzeit erkennbar. Programme in statischen Sprachen sind daher leichter zu lesen und besser berprft, die in dynamischen Sprachen leichter zu schreiben und exibler. 1.4.3 Compiler und Interpreter Java-Programme sind in der Form, in der sie geschrieben werden, nicht direkt ausfhrbar. Um das Programm ausfhrbar zu machen mssen wir darauf einen Compiler (auch bersetzer genannt) anwenden, der das JavaProgramm in ein JVM-Programm, also ein auf der JVM (Java-VirtualMachine, siehe Abschnitt 1.3.2) lauhiges Programm bersetzt. Fr den Java-Compiler ist Java die Quellsprache und die Sprache der JVM die Zielsprache; entsprechend wird Java-Code als Quellcode oder Source-Code und JVM-Code als Zielcode betrachtet. Beim Aufruf des Compilers javac knnen eine oder mehrere Klassen angegeben werden. Jedenfalls wird aus jeder Java-Klasse genau eine JVM-Klasse erzeugt, die Klassen werden also getrennt voneinander bersetzt.3 Eine JVM-Klasse, die aus einer Java-Klasse mit der speziellen Methode main (siehe Abschnitt 1.1.2) erzeugt wurde, ist durch einen JVM-Interpreter (z.B. java, vereinfacht auch Java-Interpreter genannt) ausfhrbar. JVM-Interpreter implementieren die JVM. Wenn whrend der Ausfhrung einer Methode in einer
3
Getrennte bersetzung bedeutet nicht, dass die Klassen unabhngig voneinander bersetzt werden. Wenn eine Klasse auf eine andere zugreift, muss bei der bersetzung der einen Klasse der Code der anderen vorliegen um das Zusammenpassen zu berprfen. So muss [Link] vor oder gleichzeitig mit [Link] bersetzt werden.
45
Abbildung 1.16: Vom Code zur Ausfhrung (a) mit und (b) ohne Zwischencode
JVM-Klasse auf eine andere JVM-Klasse zugegrien wird, so wird diese andere Klasse dynamisch geladen, also zum ausgefhrten Programm hinzugefgt. Auf diese Weise stehen nur solche Klassen im Speicher des Computers, die tatschlich verwendet werden. Diese bliche Vorgehensweise ist in Abbildung 1.16(a) veranschaulicht. Obige Vorgehensweise verwendet die Sprache der JVM als Zwischensprache zwischen der Quellsprache (Java) und der eigentlichen Zielsprache, das ist die natrliche Sprache der realen Maschine (Native-Language, hier kurz durch nat bezeichnet). Compiler und Interpreter laufen auf dieser Maschine, dargestellt als kleines Kstchen. Ohne Zwischencode kann man ein ganzes Java-Programm, also alle Klassen auf einmal, durch einen Compiler (beispielsweise gcj) bersetzen lassen, der eine einzige, direkt ausfhrbare Datei (beispielsweise meinProg genannt) in der natrlichen Sprache der Maschine erstellt. Das ist in Abbildung 1.16(b) veranschaulicht. Zur Ausfhrung des bersetzten Programms ist kein Interpreter ntig, weil das Programm direkt auf der Maschine luft. In T-Diagrammen wie in Abbildung 1.16 sind Compiler durch T-frmige und Interpreter durch I-frmige Symbole dargestellt. Im oberen Teil stehen Quell- und Zielsprache des Compilers bzw. die vom Interpreter interpretierte Sprache. Im unteren Teil steht die Sprache, in welcher der Compiler oder Interpreter implementiert ist. Kleine Kstchen reprsentieren Ausfhrungen auf realen Maschinen. Darunter stehende Symbole bedeuten, dass der Compiler oder Interpreter auf einer entsprechenden Maschine ausgefhrt wird. Nebeneinander stehende Symbole bedeuten, dass die links erzeugten Programmteile rechts verwendet werden. Die meisten Quellsprachen wurden entwickelt, damit Menschen damit fr Menschen gut verstndliche Programme schreiben knnen. Maschinen-
46
sprachen haben ihren Schwerpunkt in der ezienten Ausfhrbarkeit durch reale Maschinen, und abstrakte Zwischensprachen suchen einen Kompromiss aus ezienter Ausfhrung und Portabilitt. Compiler bersetzen fr Menschen optimierte Programme in fr Maschinen optimierte. Interpreter fhren Programme auf einer abstrakten Maschine aus und entkoppeln die Sprache des Programms von der natrlichen Sprache der realen Maschine. Frher galt interpretierter Code im Vergleich zu bersetztem als sehr langsam; die Interpretation hatte einen starken negativen Einuss auf die Ausfhrungsgeschwindigkeit. Mit heutigen Techniken ist die Interpretation nicht mehr so teuer, und Interpretations- und Compilationstechniken sind zusammengewachsen: Viele aktuelle Interpreter (einschlielich fast aller JVM-Interpreter) haben intern einen fr den Anwender unsichtbaren Compiler eingebaut, der hug ausgefhrte Programmteile zur Laufzeit in Maschinencode bersetzt, selten ausgefhrte Teile aber interpretiert. Dieser Ansatz heit JIT (Just-In-Time) Compilation. Er vereint die Vorteile eines Interpreters mit einer beinahe so ezienten Ausfhrung der Programme wie bei einer bersetzung im Vorhinein. Von auen gesehen handelt es sich noch immer um einen Interpreter, der portablen Zwischencode ausfhren kann. Speziell JVM-Code ist auf fast jeder Maschine ausfhrbar. Auch bei Verwendung der JIT-Technik werden Klassen dynamisch geladen, und man braucht nicht alle Klassen auf einmal zu bersetzen. Daher werden Zwischencode-Interpreter heute hug verwendet. Oft kommen fr spezielle Zwecke Sprachen zum Einsatz, die fr eine Sache sehr gut geeignet ist, fr andere aber nicht. Ein Beispiel ist die BNF
47
zur Festlegung einer Grammatik, siehe Abschnitt 1.4.1. Um die Vorteile verschiedener Sprachen miteinander zu kombinieren, geht man manchmal so vor wie in Abbildung 1.17 gezeigt: Wir schreiben in Java ein Programm, das aus einer BNF-Grammatik eine Java-Klasse generiert. Es handelt sich dabei um einen Compiler. Zuerst mssen wir ihn durch Anwendung von javac auf einem JVM-Interpreter ausfhrbar machen. Dann erzeugen wir durch Ausfhrung des BNF-Compilers aus einer Grammatik eine JavaKlasse, die wir wie blich in JVM-Code bersetzen und ausfhren knnen. Wie dieses Beispiel zeigt, ergeben sich schnell recht viele bersetzungsund Interpretationsschritte, die ntig sind, bevor ein Programm luft. 1.4.4 bersetzung und Ausfhrung Ein Interpreter funktioniert im Prinzip genauso wie eine reale Maschine, vergleiche mit Abschnitt 1.3.1: Er verwendet einen PC (ProgramCounter), der den nchsten auszufhrenden Befehl angibt, und fhrt in einer Schleife immer wieder folgende Aktionen aus: Hole den nchsten auszufhrenden Befehl entsprechend dem PC. Sorge dafr, dass der PC den danach auszufhrenden Befehl angibt. Stelle fest, was zur Ausfhrung des gerade geholten Befehls zu tun ist, und mache das. In der Klasse Zahlenraten in Listing 1.3 gehen wir hnlich vor: Im Rumpf einer Schleife holen wir ber nextInt die nchste Zahl von der Eingabe, wobei gleichzeitig die darauolgende Zahl zur nchsten Eingabe wird, stellen fest, ob die geholte Zahl kleiner oder gleich der zu erratenden Zahl ist, und geben schlielich eine entsprechende Meldung aus. Auch in einem weiteren Punkt hnelt die Klasse Zahlenraten einem Interpreter oder einer realen Maschine: Es kann vorkommen, dass ein Befehl aus irgendwelchen Grnden nicht ausfhrbar ist, so wie beim Zahlenraten vorkommen kann, dass eine Eingabe keine gltige Zahl darstellt. Beim Zahlenraten knnen wir eine falsche Eingabe einfach ignorieren und mit der nchsten Eingabe weitermachen. Aber ein Interpreter darf solche Laufzeitfehler nicht so einfach ignorieren. Die folgenden Befehle knnen ja
48
1.4 Formale Sprachen, bersetzer und Interpreter [Link]: incompatible types found : boolean required: int return (zahl == vergleichszahl); ^ 1 error Abbildung 1.18: Fehlermeldung die entsteht, wenn als Ergebnistyp von gleich in UnbekannteZahl (Listing 1.2) int statt boolean verwendet wird
davon abhngen, was vorher passiert ist. Eine Mglichkeit besteht darin, die Programmausfhrung abzubrechen und, soferne der Fehler das noch erlaubt, eine Fehlermeldung auszugeben. Einen solchen unvorhergesehenen Programmabbruch nennt man Programmabsturz. Weitere Mglichkeiten werden wir in Kapitel 5 kennenlernen. Fehler in einem Programm knnen auch whrend der bersetzung durch einen Compiler erkannt werden. Der Compiler wird in diesem Fall statt des bersetzten Codes nur eine Liste von Fehlermeldungen (engl. ErrorMessages ) ausgeben. Es ist wnschenswert, dass bereits der Compiler mglichst viele Fehler in einem Programm erkennt, damit diese Fehler nicht erst zur Laufzeit auftreten und das Programm zum Absturz bringen knnen. In Sprachen wie Java macht der Compiler zahlreiche statische Typberprfungen, wobei das Wort statisch sich darauf bezieht, dass etwas vor der Ausfhrung passiert, whrend dynamisch sich auf etwas zur Laufzeit bezieht. Die meisten Typfehler werden statisch abgefangen, siehe Abbildung 1.18. Sprachen, die nur interpretiert und nicht bersetzt werden, bieten keine Mglichkeit zur statischen Typberprfung. Manchmal erkennt der Compiler eine Situation im Programm, die unblich ist und auf einen Fehler hindeuten knnte, aber vielleicht tatschlich doch keinen Fehler darstellt. In diesen Fllen wird der Compiler das Programm zwar richtig bersetzen, aber dennoch eine Warnung (engl. Warning ) ausgeben. Warnungen sind bei der Fehlersuche hilfreich. Kein Compiler kann alle Fehler erkennen. Auch muss nicht jeder Fehler zur Laufzeit erkannt werden und zum Absturz fhren. Viele Fehler bewirken nur, dass ein Programm sich nicht so verhlt, wie wir es uns wnschen. Beispielsweise sind die berechneten Ergebnisse falsch. Diese Fehler mssen wir selbst, etwa durch intensives Testen des Programms herausnden.
49
Die Hauptaufgabe eines Compilers besteht darin, Quellcode in semantisch quivalenten Zielcode zu bersetzen. Die Semantik muss erhalten bleiben, obwohl sich die Syntax ndert. Auch die Pragmatik ndert sich, da die Pragmatik des Quellcodes auf der Ebene des Zielcodes meist keine Rolle spielt. Wir unterscheiden zwischen der statischen Semantik und der dynamischen Semantik: Die statische Semantik beschreibt, welche Flle zu einem bersetzten Programm und welche nur zu Fehlermeldungen fhren. Die dynamische Semantik beschreibt dagegen, wie sich das bersetzte Programm zur Laufzeit verhlt. Quell- und Zielcode mssen nur hinsichtlich der dynamischen Semantik quivalent sein, da sich ausschlielich der Compiler um die statische Semantik kmmert. Eine weitere Aufgabe des Compilers besteht in Optimierungen des Programms. Der Zielcode entspricht semantisch dem Quellcode, aber wenn es mehrere Mglichkeiten gibt, etwas semantisch quivalent auszudrcken, dann whlt der Compiler die Variante, die ezienter (also mit weniger Aufwand) ausfhrbar ist. Beispielsweise wird statt dem Ausdruck 3+4 der einfachere, semantisch quivalente Ausdruck 7 verwendet. Man spricht von Programmoptimierungen, obwohl der Zielcode nur selten optimal ist. Es wrde viel zu lange dauern, einen auf gewisse Weise optimalen Code zu ermitteln, selbst wenn man wei, welche Aspekte zu optimieren sind. Man mchte beispielsweise kurze Laufzeiten, geringen Speicherbedarf, kurze Antwortzeiten, geringe Netzwerkbelastungen, etc. haben. Alles auf einmal geht jedoch nicht. Beispielsweise kann man kurze Laufzeiten hug auf Kosten des Speicherbedarfs erhalten und umgekehrt, aber nicht beides gleichzeitig. Compiler versuchen daher, einen guten Kompromiss zu nden, und optimierter Code ist in vielen Aspekten meist recht gut. Oft bieten Compiler Kongurationsmglichkeiten, ber die man die wichtigsten Optimierungsziele beeinussen kann. Man kann Optimierungen als eine Form der Pragmatik auf dem Zielcode betrachten. Compiler-Optimierungen steigern die Ezienz nur geringfgig. Um Grenordnungen strker wird die Ezienz durch die Wahl geeigneter Algorithmen und Datenstrukturen bei der Programmerstellung beeinusst.
1.5 Denkweisen
Es ist eine alte Weisheit, dass unser Denken unsere Sprache beeinusst, aber umgekehrt auch unsere Sprache unser Denken. Auf gewisse Weise trit das auch auf Programmiersprachen zu. Viele Konzepte in Program-
50
1.5 Denkweisen
miersprachen haben ihren Ursprung in reinen Gedankenmodellen. Andererseits bestimmen diese Konzepte zu einem groen Teil, wie wir die Welt betrachten und in Software umsetzen. Auch losgelst von einer realen Maschine unterwerfen sich unsere Gedanken gewissen Strukturen. 1.5.1 Sprachen, Gedanken und Modelle Sprache dient dem Austausch von Gedanken. Der Sprecher teilt dem Angesprochenen seine Gedanken mit. Auf das Programmieren bertragen bedeutet das, dass wir uns berlegen, wie unsere Maschine eine gestellte Aufgabe lsen kann. Ergebnisse dieser berlegung teilen wir der Maschine in Form eines Programms mit. Im Gegensatz zur menschlichen Kommunikation ist die Kommunikation mit Maschinen scheinbar sehr einseitig, da Maschinen keine eigenen berlegungen anstellen. Als Antwort bekommen wir hchstens Fehlermeldungen oder Ergebnisse von Berechnungen zurck, die auf unseren eigenen Gedanken beruhen. Menschen knnen durch den Austausch von Gedanken gemeinsam Lsungen entwickeln. Maschinen fehlt dazu etwas Entscheidendes. Sie entwickeln keine eigenen Gedanken, sondern fhren nur vorgegebene Programme aus. Tatschlich ist das Programmieren keine so einseitige Form der Kommunikation: Programme halten vor allem auch Gedanken fest, die zwischen Menschen ausgetauscht werden. Nur selten wird Software von einer Person alleine entwickelt und gepegt. Fast immer sind mehrere Personen beteiligt, die nicht nur die Software und die Maschine genau kennen, sondern jeweils auch die Gedanken der anderen Personen verstehen mssen. Diese Gedanken sind oft auf der Ebene winzigster Details angesiedelt. Programme knnen Details recht przise ausdrcken und sind darin Texten in natrlicher Sprache berlegen. Sogar wenn man alleine Software entwickelt, kann man die eigenen Gedanken als Notizen in Form eines Programms festhalten und spter wieder in Erinnerung rufen. Wie bereits in Abschnitt 1.3.2 erwhnt, steckt hinter jeder Programmiersprache ein Berechnungsmodell. Gedanken, die wir in der Sprache ausdrcken, beziehen sich auf dieses Modell. Das Modell hat natrlich Auswirkungen auf unser Denken. Wir organisieren unsere Gedanken so, dass sie mit dem Modell in Einklang sind. Praktisch durchgesetzt haben sich nur Modelle, die sowohl einfach als auch vollstndig sind. Das Modell selbst soll einfach verstndlich sein, das Wichtigste soll sich einfach ausdrcken lassen, aber auch alles noch so Komplexe soll irgendwie ausgedrckt werden knnen. Wer Programmieren lernt, lernt vor allem, im
51
Modell hinter der Sprache zu denken. Es gibt zwar Tausende von Programmiersprachen, aber die Modelle dahinter hneln einander stark. Informatiker(innen) denken in abstrakten Modellen. Erst durch Abstraktion ber Details, das heit, durch Vernachlssigung unwichtiger Nebenschlichkeiten und Konzentration auf die wichtigsten Aspekte, knnen wir schwierige und umfangreiche Aufgaben lsen. Wir verwenden Sprachen und denken in Modellen, die uns die Konzentration auf das Wesentliche erlauben. Diese Sprachen knnen, mssen aber keine Programmiersprachen sein. Sie sollen dennoch bis zu einem gewissen Grad formal sein um Missverstndnissen vorzubeugen. Einige Sprachen sind grasch, sie drcken also etwas in Form von Zeichnungen oder Diagrammen aus, andere sind wie die meisten Programmiersprachen textuell, und wieder andere haben sowohl grasche als auch textuelle Darstellungsformen. Im Informatikstudium lernt man zahlreiche solche Modelle kennen. Beispiele dafr sind verschiedene Arten von Grammatiken und Automaten (aus der Automatentheorie) und das Entity-Relationship-Modell zur Modellierung von Beziehungen zwischen Daten. Bekannt ist vor allem UML (UniedModelling-Language), eine standardisierte grasche Sprache fr viele, vor allem in der objektorientierten Softwareentwicklung verwendete Modelle. Abbildung 1.19 zeigt ein Flussdiagramm zur Veranschaulichung des Programmusses in der Methode main in der Klasse Zahlenraten. Solche Modelle wurden hug eingesetzt um wichtige Ablufe grasch darzustellen. Auch ohne Schulung sind diese Diagramme einfach zu verstehen: Man beginnt beim Start und verfolgt die Pfeile bis zum Ende. Rauten stehen fr Verzweigungen und Kstchen fr Aktionen. Der Inhalt der Rauten und Kstchen ist wie im Beispiel meist umgangssprachlich und kurz gehalten, sodass Computer damit nichts anfangen knnen. Mit klar denierten Inhalten kann man solche Diagramme als grasche Programme verstehen, die ein Compiler in ausfhrbaren Code bersetzt. Beispielsweise sind als Kinderspielzeug gedachte Baukasten-Roboter in einer an Flussdiagramme angelehnten Sprache programmierbar. Wer in einer derartigen Sprache programmiert, erkennt rasch die Nachteile: Ein Grakeditor ist umstndlicher zu bedienen als ein Texteditor; man braucht auch fr einfache Programme recht lange. Die Programmstruktur ist oft schlecht, da Schleifen kaum als solche erkennbar sind. In Flussdiagrammen kann man nur die einfachsten Aspekte schn ausdrcken. Komplizierteres wie (geschachtelte) Methodenaufrufe oder Objekte und Klassen sind nicht bersichtlich darstellbar. Heute werden Flussdiagramme nur mehr selten verwendet.
52
1.5 Denkweisen
Start ?
Initialisierung out: "nchster Versuch" ? H H out: "nicht nein H H Input? gefunden" HH ja ? ? H Input InputHHnein
Ende H Zahl? entfernen HH ja ? ? Zahl out: "falsche einlesen Eingabe"
? HH ja H nicht 0..99? H HH
nein out: "gefunden"
?
Ende
Struktogramme wie in Abbildung 1.20 stellen die Blockstruktur eines Programms in den Mittelpunkt. Die Aktionen in den Kstchen werden von oben nach unten nacheinander ausgefhrt. Spezielle Darstellungsformen gibt es fr Schleifen (mit einem oenen Balken am Rande, der bis zum Ende der Schleife reicht) und fr Verzweigungen (Dreieck im obersten Kstchen, das die Bedingung enthlt, darunter die alternativen Zweige nebeneinander). Fr einen vorzeitigen Ausstieg gibt es ein Kstchen, das auf
53
h hhh h
ja
hhhh
Input ist ganze Zahl? hhh hhh hhh hhh nein ( (((
nein Input entfernen
H H H
ja
gleich? nein HH Output: H Output: HH Output: kleiner? "falscher H "gefunden" ja H nein "falsche H Werteber." Eingabe" Output: Output: return "Zahl kleiner""Zahl grer" A A Output: "nchster Versuch" Output: "Zahl leider nicht gefunden"
der linken Seite einen ber zwei kleine Dreiecke symbolisierten Pfeil enthlt siehe Abbildung 1.20. Durch die Blockstruktur werden Schleifen und Verzweigungen so eingeschrnkt, dass die unverstndlichsten Formen von Programmssen (die wir in Abschnitt 6.4.1 ansprechen werden) nicht ausdrckbar sind. Dagegen spiegelt das Struktogramm die Struktur einer Methode in Java (und anderen aktuellen Sprachen) recht direkt wider. Abgesehen von der besseren Struktur leiden Struktogramme jedoch fast an allen Schwchen, die Flussdiagramme aufweisen. Auch Struktogramme werden nur noch selten verwendet. Das Hauptproblem besteht darin, dass komplexere Aspekte wie (geschachtelte) Methodenaufrufe nicht bersichtlich darstellbar sind. Leute mit viel Erfahrung schreiben und lesen lieber direkt den Quellcode als irgendwelche davon abgeleiteten Diagramme. Programmiersprachen sind dafr ausgelegt, sowohl Details przise auszudrcken als auch ber Details zu abstrahieren. Bereits in Abschnitt 1.1.1 haben wir den Begri der Datenabstraktion (als Datenkapselung zusam-
54
1.5 Denkweisen
men mit Data-Hiding) eingefhrt. Datenabstraktion erlaubt uns, Objekte als abstrakte Einheiten zu sehen, die Objekte aus der realen Welt simulieren und daher intuitiv gut verstndlich sind. Durch genau beschriebene Objektschnittstellen bleibt die Przision trotz Abstraktion erhalten. Abstraktion bildet also keinen Gegensatz zur Przision. Der Begri der Funktion hat in fast allen Programmiersprachen eine zentrale Bedeutung. Darunter verstehen wir eine Abbildung von einer Wertemenge auf eine andere Wertemenge. Funktionen in Programmiersprachen sind intensional speziziert: Sie beschreiben einen Algorithmus zur Berechnung des Funktionsergebnisses. Dieses wird durch Ausfhrung der Funktion und damit des entsprechenden Algorithmus berechnet. In Java bernehmen Methoden die Rolle von Funktionen, obwohl sich Methoden in einigen Aspekten von Funktionen unterscheiden. Auch Begrie wie Prozedur und Routine werden fr funktionshnliche Sprachelemente verwendet. Eine Gemeinsamkeit besteht darin, dass bei Ausfhrung (gleichbedeutend: bei einer Anwendung, nach einem Aufruf, nach dem Senden und anschlieenden Empfangen einer Nachricht) die spezizierten Berechnungsschritte durchgefhrt werden. Jedoch mssen Methoden, Prozeduren und Routinen nicht notwendigerweise Ergebnisse zurckliefern wie main in Java. Ergebnisse knnen nicht nur von Parametern abhngen, sondern auch von Eingaben und Werten in den Variablen von Objekten. Auerdem kann es Seiteneekte geben: Die Berechnungsschritte knnen Ausgaben machen und neue Werte an Variablen zuweisen. Wenn man Funktionen ohne Seiteneekte meint, deren Ergebnisse nur von Parametern abhngen, spricht man von reinen Funktionen (engl. pure functions ). Nach der wichtigsten Form der Abstraktion kann man Programmiersprachen in mehrere Kategorien einteilen: Imperative Sprachen: Der Name kommt von der Befehlsform, in der man einer Maschine Anweisungen gibt. An oberster Stelle steht die Mglichkeit, neue Werte an Variablen zuzuweisen. Durch Befehle und Variablen ist die Rechnerarchitektur hinter dem Berechnungsmodell deutlich erkennbar. Man unterscheidet folgende Varianten: Prozedurale Sprachen verwenden zur Abstraktion im Wesentlichen Prozeduren mit Seiteneekten. Reine Funktionen reichen nicht aus, da Berechnungsfortschritte nur ber Zuweisungen erfolgen. Objektorientierte Sprachen stellen eine Erweiterung prozeduraler Sprachen dar. Objekte und Datenabstraktionen werden wichtiger als die Abstraktion ber Prozeduren.
55
Deklarative Sprachen: Berechnungsmodelle sind nahe an mathematische Modelle angelehnt. Elemente von Rechnerarchitekturen sind kaum erkennbar. Es gibt keine Zuweisung, die den alten Wert einer Variablen berschreiben wrde. Funktionale Sprachen verwenden zur Abstraktion reine Funktionen. Das Fehlen von Seiteneekten erleichtert das Verstndnis eines Programms in gewisser Weise. Andererseits erfordern Einund Ausgaben eine bestimmte Strukturierung des Programms. Logikorientierte Sprachen haben ihre Wurzeln in der mathematischen Logik, und die Programmausfhrung ist gleichbedeutend mit dem Beweis einer logischen Aussage. Trotzdem hnelt die Programmausfhrung der eines prozeduralen Programms. Jedoch werden Werte nur an freie Variablen zugewiesen, nicht an Variablen, die bereits Werte enthalten. Wegen des starken Einusses der Abstraktionen auf Denkweise und Programmierstil entsprechen diese Kategorien den wichtigsten Programmierparadigmen. Es ist leicht zu sehen, dass Funktionen und funktionshnliche Sprachelemente, direkt oder indirekt, in jedem Paradigma vorkommen. 1.5.2 Der Lambda-Kalkl Der in den 1930er Jahren von Church und Kleen entwickelte LambdaKalkl, benannt nach dem griechischen Buchstaben , bildet eine formale Basis fr Funktionen und daher fr praktisch alle Programmiersprachen. Wegen seiner Wichtigkeit wollen wir diesen Kalkl nher betrachten. Zunchst denieren wir die Menge der -Ausdrcke Exp, die im Kalkl eine Bedeutung haben die syntaktisch richtigen Ausdrcke. Als Basis dafr verwenden wir eine unendliche Menge V von Variablen. Anders als in Java knnen wir keine Werte an diese Variablen zuweisen. Sie haben eher die Bedeutung von Variablen in der Mathematik, stehen also fr unbekannte Werte. Folgende Ausdrcke kommen in Exp vor: Variable: Funktionsanwendung: Funktionsabstraktion: v Exp wenn v V f e Exp wenn f Exp und e Exp v.f Exp wenn v V und f Exp
Im -Ausdruck f e wird f als Funktion aufgefasst, die auf das Argument e angewandt wird. In vielen Programmiersprachen schreibt man meist
56
1.5 Denkweisen
Klammern um das Argument, etwa sqrt(2) fr die Wurzel aus 2. In einem -Ausdruck darf man zwar auch Klammern hinschreiben, man kann sie wie in sqrt 2 aber auch einfach weglassen. Der Ausdruck v.f deniert eine Funktion, wobei v dem formalen Parameter (ohne Typ) und f dem Ergebnis der Funktion entspricht. So ist v.v die Identittsfunktion, die nur das Argument zurckgibt. In vielen Programmiersprachen verwendet man bei der Anwendung einer Funktion nur den Namen einer Funktion wie sqrt in sqrt(2) und deniert die Funktion an einer anderen Stelle. In -Ausdrcken deniert man die Funktion dagegen direkt an der Stelle, an der die Anwendung erfolgt. Man schreibt (v.v ) 2 wenn man die Identittsfunktion auf 2 anwendet. Runde Klammern wie in (v.f ) e sind nicht Teil der Syntax, sondern werden bei Bedarf verwendet um die Struktur der -Ausdrcke zu verdeutlichen. Funktionen werden wie Daten behandelt und knnen als Argumente an andere Funktionen bergeben oder als Ergebnisse zurckgegeben werden. Hier bezeichnen wir Elemente der Menge V durch u, v, w, . . . und Elemente von Exp durch e, f, g, . . . Man sagt, eine Variable in einem Ausdruck ist gebunden, wenn sie nur in Funktionen vorkommt, die diese Variable als formale Parameter verwenden. Gebundene Variablen stehen fr die Argumente, auf welche die Funktionen angewandt werden. Eine Variable kommt in einem Ausdruck frei vor, wenn sie nicht gebunden ist. Freie Variablen stehen fr beliebige Ausdrcke. Beispielsweise ist v in v.(u v ) gebunden und u ist frei. Mit FV(e) bezeichnen wir die Menge aller in e frei vorkommenden Variablen: FV(v ) = {v } wobei v V FV(f e) = FV(f ) FV(e) FV(v.f ) = FV(f ) \ {v } (v ist nicht in der Menge) Zur Denition der Semantik des Kalkls bentigen wir den Begri der Ersetzung: Ein Ausdruck [e/u]f (gesprochen: e ersetzt u in f ) steht fr den -Ausdruck, der entsteht, wenn man im -Ausdruck f jedes freie Vorkommen der Variablen u durch den -Ausdruck e ersetzt: [e/u]v = e wenn u = v v wenn u = v und v V
v.f
[e/u](f g ) = ([e/u]f ) ([e/u]g ) wenn u = v wenn u = v und v FV(e) [e/u](v.f ) = v.[e/u]f w.[e/u][w/v ]f sonst, wobei u = w = v und w FV(f e)
57
Die Ersetzung auf Funktionsabstraktionen (dritte Regel) bedarf einiger Erklrungen: Wenn die Variable u, die ersetzt werden soll, gleich dem formalen Parameter v ist, brauchen wir nichts machen, da diese Variable in der Funktion ja nicht frei, sondern nur gebunden vorkommt. Wenn sich die Variablen unterscheiden (u = v ), mssen wir die Ersetzung auch im Ergebnis vornehmen. Dabei darf aber kein Konikt zwischen dem formalen Parameter v und frei in e vorkommenden Variablen auftreten, es muss also v FV(e) gelten. Wenn es doch einen Konikt gibt (letzte Alternative), mssen wir den formalen Parameter umbenennen. Das heit, wir mssen eine andere Variable w als formalen Parameter verwenden, die sich sowohl von u als auch v unterscheidet, und die weder in f noch in e frei vorkommt. Beispielsweise ergibt [v/u]v.((v u) u) einen Ausdruck w.((w v ) v ). Drei einfache Regeln bestimmen die Semantik des Lambda-Kalkls: -Konversion: -Konversion: -Konversion: v.f u.[u/v ]f wobei u FV(v.f ) (v.f ) e [e/v ]f v.(f v ) f wobei v FV(f )
ber diese Regeln wird eine quivalenz zwischen -Ausdrcken deniert. Zwei -Ausdrcke e0 und en sind quivalent (also quasi gleich) wenn es mglich ist, durch beliebig oft wiederholte Anwendungen der Regeln e0 in en umzuformen, das heit, wenn es -Ausdrcke e1 , . . . , en1 gibt, sodass nach obigen Regeln ei ei+1 oder ei+1 ei fr alle 0 i < n gilt. Die Konversions-Regeln sind nicht gerichtet. Sie sind von links nach rechts genauso anwendbar wie von rechts nach links. quivalenz verwenden wir als Basis fr Berechnungen: Wir suchen nach einem Ergebnis, das quivalent zur gestellten Aufgabe, aber so stark vereinfacht wie mglich ist. Der Ausdruck rechts vom Pfeil ist fr - und -Konversionen einfacher als der links vom Pfeil. Wenn wir einen -Ausdruck reduzieren, also vereinfachen wollen, wenden wir diese Regeln nur von links nach rechts an: -Reduktion: -Reduktion: (v.f ) e [e/v ]f v.(f v ) f wobei v FV(f )
Die -Konversion (ausgesprochen: alpha-Konversion) heit auch Umbenennung. Formale Parameter drfen beliebig umbenannt werden (wobei die Umbenennungen im ganzen Ausdruck auf gleiche Weise erfolgen mssen), solange es dabei zu keinen Namenskonikten kommt. Beispielsweise gilt u.u v.v . Aufgrund der -Konversion knnen wir Umbenennungen, die in der letzten Alternative in der Denition von Ersetzungen not-
58
1.5 Denkweisen
wendig sind, jederzeit auch ohne zwingenden Grund durchfhren. Umbenennungen haben keine Richtung und vereinfachen nichts. Deshalb gibt es auch keine -Reduktion, sondern nur eine -Konversion. Die -Reduktion (beta-Reduktion) bzw. Funktionsanwendung ist die wichtigste Regel: Das Ergebnis ist der Ausdruck rechts vom Punkt, wobei jedes freie Vorkommen des formalen Parameters durch den aktuellen Parameter ersetzt ist. Zum Beispiel wird (v.v ) e zu e reduziert. Die -Reduktion (eta-Reduktion) oder Erweiterungs-Regel spielt nur eine untergeordnete Rolle. Beispielsweise kann (v.(f v )) e mit v FV(f ) sowohl durch -Reduktion als auch durch -Reduktion zum Ausdruck f e reduziert werden. Die -Reduktion verlangt einen Funktionsrumpf in einer Form, in der das Ergebnis nicht vom Argument abhngt, und braucht daher kein Argument, whrend die -Reduktion immer ein Argument haben muss. Wenn beide Regeln anwendbar sind, liefern sie dasselbe Ergebnis. Ein -Ausdruck ist in Normalform, wenn darauf weder eine - noch -Reduktion anwendbar ist. Beispiele sind v.v und u v . Ausdrcke in Normalform entsprechen den Endergebnissen von Berechnungen. Beim Reduzieren eines Ausdrucks, also bei der Suche nach einer Normalform, geht man folgendermaen vor: Man sucht irgendeine Stelle im Ausdruck, auf welche die linke Seite einer Reduktionsregel passt. Darauf wendet man die Regel an. berssige Klammern im Ergebnis kann man weglassen. Fast immer wird man eine -Reduktion anwenden, da -Reduktionen sehr selten vorkommen. Wenn es mehrere Mglichkeiten gibt, whlt man irgendeine davon. Das Ergebnis reduziert man weiter, bis es keine Reduktionsmglichkeit mehr gibt. Als Beispiel reduzieren wir folgenden Ausdruck (wobei a, b, p, x, y V ): (p.(a.(b.((p b) (p a))))) (x.(y.x)) Wir knnen eine -Reduktion (v.f ) e [e/v ]f anwenden. Dabei steht v fr p, der Teilausdruck (a.(b.((p b) (p a)))) fr f und (x.(y.x)) fr e. Die Reduktion ergibt den Ausdruck [(x.(y.x))/p](a.(b.((p b) (p a)))). Durch Ersetzung von p durch (x.(y.x)) und Weglassen unntiger Klammern entsteht folgender Ausdruck: a.(b.(((x.(y.x)) b) ((x.(y.x)) a))) Darin gibt es zwei Mglichkeiten fr die Anwendung einer -Reduktion auf Teilausdrcke. Zur Demonstration probieren wir alle Lsungsvarianten aus, obwohl eine Variante zur Lsung der Aufgabe reichen wrde:
59
1. Wir reduzieren den Teilausdruck (x.(y.x)) b, wobei wir x fr v , (y.x) fr f und b fr e verwenden. Das ergibt den Ausdruck a.(b.((y.b) ((x.(y.x)) a))) in dem wieder zwei -Reduktion anwendbar sind: a) Wir reduzieren den Teilausdruck (x.(y.x)) a, wobei wir x fr v , (y.x) fr f und a fr e verwenden. Das ergibt den Ausdruck a.(b.((y.b) (y.a))) in dem eine weitere -Reduktion anwendbar ist. Wir reduzieren den Teilausdruck (y.b) (y.a), wobei wir y fr v , b fr f und (y.a) fr e verwenden. Das ergibt einfach a.(b.b) da y im Ausdruck b nicht vorkommt. Weitere Reduktionen gibt es nicht, a.(b.b) ist also in Normalform. b) In a.(b.((y.b) ((x.(y.x)) a))) knnen wir auch zuerst den Teilausdruck (y.b) ((x.(y.x)) a) reduzieren, wobei wir y fr v , b fr f und ((x.(y.x)) a) fr e verwenden. Damit erhalten wir sofort a.(b.b) da y im Ausdruck b nicht vorkommt. 2. In a.(b.(((x.(y.x)) b) ((x.(y.x)) a))) wird zuerst (x.(y.x)) a reduziert, wobei x fr v , (y.x) fr f und a fr e steht. Das ergibt den Ausdruck a.(b.(((x.(y.x)) b) (y.a))). Eine -Reduktion auf (x.(y.x)) b ergibt a.(b.((y.b) (y.a))), und eine -Reduktion auf (y.b) (y.a) ergibt schlielich die Normalform a.(b.b). Wir erhalten in jeder Variante dieselbe Normalform, obwohl wir unterschiedliche und unterschiedlich viele Reduktionsschritte durchfhren. 1.5.3 Eigenschaften des Lambda-Kalkls Der Lambda-Kalkl hat einige Eigenschaften, die ihn als Grundlage fr Programmiersprachen wertvoll machen. Einer davon ist die Einfachheit. Wir brauchen nicht mehr als eine Konversions-Regel und zwei ReduktionsRegeln, um die Semantik einer Programmiersprache zu beschreiben. Tatschlich lassen sich die meisten Ausdrcke durch wiederholte Anwendung einer einzigen Regel, der -Reduktion zu einer Normalform reduzieren. Hier sind einige weitere wichtige Eigenschaften:
60
1.5 Denkweisen
Vollstndigkeit: Der Lambda-Kalkl ist Turing-vollstndig. Man geht davon aus, dass alles, was irgendwie berechenbar ist, auch im LambdaKalkl berechenbar ist. Leider sind gerade einfache Operationen wie die Addition in diesem Kalkl nur umstndlich ausdrckbar. Daher verwendet man hug kombinierte Systeme, in denen primitive Berechnungen ber andere Formalismen erfolgen und kompliziertere Flle den -Ausdrcken vorbehalten sind. Nehmen wir an, dass die Addition e + f , die Subtraktion e f , der Vergleich e < f und der bedingte Ausdruck e ? f : g auf andere Weise deniert sind. Dabei ist e ? f : g zu f reduzierbar, wenn e zu true reduzierbar ist, und e ? f : g zu g wenn e zu false reduzierbar ist.4 Weiters soll F = u.(v.((1 < v ) ? (v + ((u u) (v 1))) : v )) gelten. Dann ist der Ausdruck (F F ) n fr jede natrliche Zahl n zur Summe der natrlichen Zahlen von 1 bis n reduzierbar, F F ist also die Summen-Funktion siehe Abschnitt 4.1.1. Zur Ausfhrung reichen -Reduktionen und Auswertungen von +, , < und ? : aus. Endlos-Reduktionen: Nicht zu jedem -Ausdruck gibt es einen quivalenten Ausdruck in Normalform. Das bedeutet, manchmal sind Reduktionen endlos wiederholt anwendbar. Beispielsweise fhrt eine -Reduktion von (v.(v v )) (v.(v v )) wieder zu genau demselben Ausdruck. Solche endlosen Reduktionen sind in Turing-vollstndigen Systemen, nicht nur im Lambda-Kalkl, prinzipiell nicht vermeidbar. Reihenfolge von Reduktionen: Es spielt fast keine Rolle, in welcher Reihenfolge wir die Regeln anwenden. Beispielsweise knnen wir zuerst Argumente reduzieren, oder zuerst die uerste Funktion. Das Ergebnis, das heit, die berechnete Normalform hngt nicht davon ab, abgesehen von mglichen Umbenennungen formaler Parameter. Falls eine Berechnung zu einem Ergebnis fhrt, dann fhrt jede einigermaen gerechte Reihenfolge der Anwendung von Reduktions-Regeln zum selben Ergebnis. Eine Ausnahme bilden Ausdrcke, in denen ein Teilausdruck, der im Ergebnis gar nicht vorkommt, endlos reduzierbar ist, beispielsweise (u.w) ((v.(v v )) (v.(v v ))). Hier fhrt eine einzige Anwendung der -Reduktion auf der ersten Funktionsabstraktion zur Normalform w, whrend wir kein Ergebnis erhalten, wenn wir stets nur das Argument (v.(v v )) (v.(v v )) reduzieren.
4
Den Bedingungsoperator ? : gibt es mit derselben Bedeutung auch in Java, wie wir in Abschnitt 2.2.2 sehen werden.
61
Funktionen erster Ordnung: Funktionen werden wie Daten behandelt. Man sagt, sie sind Elemente erster Ordnung (First-Class-Entities ). Funktionen knnen als Argumente verwendet und als Ergebnisse zurckgegeben werden, so wie im -Ausdruck (v.v ) (u.e). Keine Kontrollstrukturen: In allen imperativen Sprachen spielen Kontrollstrukturen wie bedingte Anweisungen und Schleifen eine groe Rolle. Der Lambda-Kalkl kommt ohne Kontrollstrukturen aus, da sie durch Funktionen erster Ordnung ersetzt werden knnen. Currying: Eine Funktion wird im Lambda-Kalkl immer nur auf ein einziges Argument angewandt. Durch Funktionen erster Ordnung ist das keine Einschrnkung: Zum Beispiel ist ((u.(v.(v u))) e) f durch eine -Reduktion zu (v.(v e)) f und durch eine weitere zu f e reduzierbar. Wir knnen u.(v.(v u)) als Funktion mit zwei Parametern betrachten (die wir auf die beiden Argumente e und f angewendet haben), oder gleichbedeutend als eine Funktion, die durch Anwendung auf ein Argument eine (auf ein weiteres Argument anwendbare) Funktion zurckgibt. Diese Technik nennt man Currying. Der Lambda-Kalkl erfllt seine Aufgabe als Berechnungsmodell hervorragend. Aber -Ausdrcke sind kaum lesbar, und ohne Erweiterungen gibt es keine Mglichkeit zur Beschreibung von Datenstrukturen und Programmorganisation. In der Praxis wnschen wir uns zumindest einfache Kontrollstrukturen, mehr Kontrolle (z.B. indem wir die Reihenfolge der Reduktionen bestimmen knnen), benannte Funktionen statt namenloser Funktionsabstraktionen und vielleicht auch ein statisches Typsystem. Der Kalkl lsst sich entsprechend erweitern. Leider verlieren wir dadurch etwas, nmlich die einfache Analysierbarkeit. Praxistaugliche Programmiersprachen sind immer viel komplexer als einfache formale Modelle. Formale Modelle verraten uns viel ber Sprachen, z.B. Folgendes: Entscheidbarkeit: Man kann formal nachweisen, dass nicht alle Probleme entscheidbar, also lsbar sind. Andererseits ist es gar nicht schwer, Turing-vollstndige Systeme wie den Lambda-Kalkl zu entwickeln, in denen alle bisher als entscheidbar bekannten Probleme lsbar sind. In jedem solchen System sind aber auch unentscheidbare Probleme ausdrckbar. Im Lambda-Kalkl uert sich das durch Endlosreduktionen, die niemals zu einer Normalform fhren.
62
1.5 Denkweisen
Unentscheidbarkeit des Halteproblems: Ein bekanntes unentscheidbares Problem ist das Halteproblem: Im Allgemeinen ist nicht entscheidbar, ob wiederholte -Reduktionen jemals zu einer Normalform fhren oder nicht. Genau aus diesem Grund knnen wir weder den Lambda-Kalkl noch irgendein anderes Turing-vollstndiges System oder eine Programmiersprache so einschrnken, dass nur entscheidbare Probleme ausdrckbar sind. Wenn wir Endlosreduktionen vermeiden, geht zwangslug die Vollstndigkeit verloren. Genaugenommen ist das Halteproblem, wie auch viele anderen Probleme, halbentscheidbar, das heit, in manchen Fllen ist das Problem entscheidbar, in anderen nicht. Manchmal wissen wir, dass eine Berechnung terminiert (also eine Normalform nach endlich vielen Reduktionen gefunden wird), manchmal wissen wir, dass eine Berechnung niemals terminiert, und manchmal knnen wir weder das eine noch das andere feststellen. In der Praxis ist die Unentscheidbarkeit des Halteproblems nur selten von Bedeutung. Bei der Programmierung bemhen wir uns ja darum, nur Algorithmen einzusetzen, die nach relativ kurzer Zeit terminieren. Dabei stoen wir kaum an die Grenzen der Entscheidbarkeit. Scheinbar endlose Berechnungen (ob sie tatschlich endlos sind, wissen wir ja oft nicht) deuten eher darauf hin, dass das Programm fehlerhaft ist als dass wir ein unentscheidbares Problem lsen wollen. 1.5.4 Zusicherungen und Korrektheit Beim Programmieren passieren leicht Fehler. Ursachen dafr sind vielfltig: Fehler im Verstndnis der logischen Zusammenhnge, Kommunikationsfehler zwischen Personen, aus Unachtsamkeit oder Zeitdruck nur unvollstndig durchgefhrte oder vergessene nderungen, Unbersichtlichkeit groer Systeme, und so weiter. Wir mssen etwas gegen die wichtigsten Fehlerursachen tun. Gegen die Unbersichtlichkeit knnen wir vorgehen, indem wir ein groes System in bersichtlichere Teile zerlegen. Kommunikationsfehler und Fehler im logischen Verstndnis knnen wir vermindern, indem wir unsere Intentionen im Programm klar machen. Dabei helfen uns Typen und Kommentare. Sie untersttzen uns dabei, das schwer fassbare dynamische Verhalten des Programms auf die einfacher verstndliche statische Ebene zu bringen. Auch die Intuition untersttzende Einrckungen sind sehr hilfreich. Gut gewhlte Namen und Analogien zur realen Welt verbessern ebenfalls die statische Verstndlichkeit.
63
Listing 1.21: Zusicherungen als Kommentare (Code aus Listing 1.2) zahl = (new Random()).nextInt() % grenze; // -grenze < zahl und zahl < grenze (wegen ... % grenze) if (zahl < 0) { // -grenze < zahl und zahl < 0 (wegen Bedingung zahl < 0) zahl = zahl + grenze; // 0 < zahl und zahl < grenze (wegen Addition von grenze) } // 0 < zahl und zahl < grenze (wenn Bedingung wahr war) // oder 0 <= zahl und zahl < grenze (wenn Bedingung falsch war) // ergibt 0 <= zahl und zahl < grenze
Listing 1.22: Zusicherungen als assert-Anweisungen zahl = (new Random()).nextInt() % grenze; assert((-grenze < zahl) && (zahl < grenze)); if (zahl < 0) { assert((-grenze < zahl) && (zahl < 0)); zahl = zahl + grenze; assert((0 < zahl) && (zahl < grenze)); } assert((0 <= zahl) && (zahl < grenze));
Zusicherungen beschreiben relevante Ausschnitte aus dem erwarteten Zustand eines Objekts oder Systems an der richtigen Stelle im Programm auf systematische Weise. Im einfachsten Fall verwenden wir Kommentare als Zusicherungen zwischen Anweisungen, wie in Listing 1.21 (wobei <= fr kleiner oder gleich steht). Auf diese Weise knnen wir einfach verstehen, in welchem Bereich der Wert von zahl liegt, ohne den dynamischen Programmablauf nachvollziehen zu mssen. Leider wissen wir nicht, ob der Inhalt der Kommentare stimmt. Er wird ja nirgends berprft. Wenn wir berprfungen haben mchten, verwenden wir statt der Kommentare assert-Anweisungen wie in Listing 1.22. Dabei steht der Operator && fr die logische UND-Verknpfung. Die Bedingungen in den assert-Anweisungen werden bei entsprechendem Interpreteraufruf (siehe Abschnitt 5.2.1) zur Laufzeit jedes Mal berprft, wenn diese Stellen im Programm ausgefhrt werden. Falls eine Zusicherung nicht erfllt ist,
64
1.5 Denkweisen
tritt ein Laufzeitfehler auf. Allerdings ist nicht garantiert, dass eine falsche Zusicherung gleich erkannt wird. Wenn wir beispielsweise statt 0<=zahl in der letzten Zeile die zu strenge Zusicherung 0<zahl machen wrden, mssten wir dieses Programmstck oft wiederholt ausfhren, bis zahl zufllig einmal den Wert 0 bekommt und der Fehler aullt. Mit einigen wenigen Testdurchlufen ist dieser Fehler nicht zu entdecken. Wenn man aufmerksam ist, knnen solche Fehler schon beim Hinschreiben der Zusicherungen gleichgltig ob als assert-Anweisung oder Kommentar auallen. Also auch ohne berprfung tragen Zusicherungen zur Fehlervermeidung bei. Der wichtigste Beitrag von Zusicherungen zur Fehlervermeidung besteht darin, dass wir uns beim Programmieren berlegen mssen, ob die Zusicherungen halten knnen. Ohne schriftlich festgehaltene Zusicherungen vergessen wir leicht darauf. Besonders wichtig sind Zusicherungen dort, wo man den Programmablauf nicht anhand weniger Anweisungen nachvollziehen kann. Das trit auf Schnittstellen zwischen Programmteilen zu, besonders auf Schnittstellen von Funktionen und hnlichem. Beispielsweise knnen wir auf dem Konstruktor von UnbekannteZahl folgende Zusicherungen haben:
Listing 1.23: Kommentare als Zusicherungen auf Konstruktor // Initialisierung mit Zufallszahl x; 0 <= x; x < grenze // Voraussetzung: grenze > 0 public UnbekannteZahl(int grenze) { ... }
Die Zusicherungen sollen den Konstruktor so beschreiben, dass man den Code im Rumpf gar nicht kennen muss um zu verstehen, was er macht. Wir verlassen uns eher auf Kommentare als auf den Code. Zum Teil knnten wir auch fr Schnittstellenbeschreibungen assert-Anweisungen (innerhalb des Rumpfes) verwenden, aber nicht fr alles. Es wre kaum mglich, in einer assert-Anweisung festzulegen, dass die Initialisierung mit einer Zufallszahl erfolgt. Mit einem Kommentar ist das einfach. Es gibt zumindest zwei Arten von Zusicherungen auf Schnittstellen: Vorbedingung: Diese Bedingung muss erfllt sein, bevor die Funktion, Methode, etc. ausgefhrt werden kann. Das betrit vor allem Einschrnkungen auf formalen Parametern. Im Beispiel wre das die Bedingung grenze>0. Die Vorbedingung muss bereits bei der Anwendung einer Funktion bzw. beim Senden einer Nachricht erfllt sein. Die Funktion, Methode, etc. hat selbst keine Mglichkeit, dafr zu sorgen, dass die Vorbedingung erfllt ist.
65
Nachbedingung: Eine Nachbedingung muss whrend der Ausfhrung der Funktion, Methode, etc. erfllt werden. Die erste Kommentarzeile im Beispiel ist eine Nachbedingung. Nach Ausfhrung muss die Initialisierung mit einer Zufallszahl innerhalb der Grenzen erfolgt sein. Gerade in der objektorientierten Programmierung ist der Rumpf der ausgefhrten Methoden sehr oft unbekannt, sodass solche Zusicherungen die einzige Mglichkeit darstellen, um das Verhalten zu beschreiben. Zusicherungen kann man auch verwenden, um die Semantik einzelner Elemente in Programmiersprachen zu denieren. Bekannt ist der nach C.A.R. Hoare benannte Hoare-Kalkl. Ausdrcke in diesem Kalkl haben die Form {P } S {Q}, wobei die Vorbedingung P und die Nachbedingung Q Ausdrcke aus der Prdikatenlogik sind und S eine Anweisung (Statement) ist. Durch sorgfltige Wahl von P und Q wird eine manchmal recht genaue Spezikation der Semantik von S ber mathematisch einfach handhabbare Mittel erreicht. Es gibt auch einige andere Techniken zur Spezikation der Semantik, die alle Vor- und Nachteile haben.
1.6 Softwareentwicklung
Die Programmierung ist ein wichtiger Teil der Softwareentwicklung. Wir wollen nun das Umfeld beschreiben, in dem die Programmierung zum Einsatz kommt, sowie einige Ziele, die wir in der Programmierung anstreben. 1.6.1 Softwarelebenszyklus Jede Software hat einen Lebenszyklus, der bei der ersten Idee beginnt und mit der letzten Anwendung endet. Dazwischen liegt die Entwicklung (Development), Wartung (Maintenance) und Anwendung (Use) der Software. Folgende Entwicklungsschritte bzw. -phasen werden unterschieden: Analyse (Analysis): In dieser Phase wird die Aufgabe, die durch die zu entwickelnde Software gelst werden soll, analysiert. Meist ist die Aufgabe anfangs nur grob umrissen, und es ist erst herauszunden, was die Software tun soll. Das Ergebnis der Analyse ist eine Anforderungsdokumentation, in der klare Anforderungen festgelegt sind. Entwurf (Design): Ausgehend von den Anforderungen wird die Struktur bzw. Architektur der Software in der Entwurfsdokumentation festgelegt. Der Entwurf umfasst alle Betrachtungsebenen, von der obersten
66
1.6 Softwareentwicklung
Architekturebene bis hinunter zu Details im gewnschten Verhalten einzelner Objekte. Vereinfachend kann man sagen, dass die abstrakten Maschinen in der zu entwickelnden Software beschrieben werden. Implementierung (Implementation): Die Implementierung ist die Ttigkeit der Umsetzung des Entwurfs in ein Programm. Auch das Ergebnis dieser Ttigkeit nennt man Implementierung. Man implementiert also die in der Entwurfsdokumentation beschriebenen abstrakten Maschinen. Unter Programmierung im engeren Sinn versteht man das, was in der Implementierungsphase gemacht wird. Verikation (Verication): Durch die Verikation wird berprft, ob bereits implementierte Software der Anforderungsdokumentation entspricht. Hier wird der Kreis zum Ergebnis der Analyse geschlossen um Fehler im Entwurf und in der Implementierung zu nden. Unter Verikation im engeren Sinn versteht man formale berprfungen, whrend im weiteren Sinn jede Form der berprfung zulssig ist. Eine wichtige Form der nicht-formalen berprfung ist ein CodeReview, bei dem erfahrene Personen den Programmcode lesen und auf bereinstimmung mit den Anforderungen hin analysieren. Testen (Testing): Beim Testen wendet man die Software systematisch auf sorgfltig gewhlte Testflle an um Fehler aufzudecken. Im Gegensatz zur formalen Verikation knnen Fehler durch Testen nur mit einer bestimmten Wahrscheinlichkeit entdeckt werden, abhngig vom Umfang und der Qualitt der Testflle. Durch Testen ist es jedoch auch mglich, Fehler in der Analyse selbst oder zufllig in einem Bereich zu nden, der durch die formale Verikation nicht abgedeckt ist. Validierung (Validation): Unter Validierung versteht man die berprfung der Software hinsichtlich einer breiten Palette von Zielen. Man mchte beispielsweise feststellen, ob und wie gut die Software die tatschlichen Aufgaben bestimmter Anwender erfllen kann, oder ob die Qualitt und Praxisrelevanz der Software deren Anschaung oder Weiterentwicklung rechtfertigt. Die Validierung schliet alle Entwicklungsphasen ein, auch die Analyse und Verikation bzw. das Testen. Diese Entwicklungsschritte werden meist nicht nur hintereinander, einer nach dem anderen durchgefhrt, sondern berlappend. Das heit, man analysiert, entwirft, implementiert, etc. zuerst nur einen kleinen Teil der
67
Software und wiederholt diese Schritte fr andere Teile, noch bevor alle Schritte fr die ersten Teile durchgefhrt sind. Damit will man gesammelte Erfahrungen so rasch wie mglich nutzen. Man spricht von zyklischen Softwareentwicklungsprozessen, da die einzelnen Schritte zyklisch wiederholt werden, auch wenn die Zyklen nur selten klar voneinander abgegrenzt sind. Die schrittweise Verfeinerung bezieht sich darauf, dass die Software zuerst nur in groben Zgen vorliegt, aber stetig verfeinert wird. An die Entwicklungsphase schliet die Wartungsphase an. Dabei wird die sich schon im praktischen Einsatz bendliche Software gepegt, also im laufenden Betrieb festgestellte Fehler korrigiert und die Software im notwendigen Ausma an sich ndernde Bedingungen und Anforderungen angepasst. nderungen in der Wartungsphase umfassen alle Entwicklungsschritte von der Analyse bis zu Verikation, Test und Validierung. Jedoch erfordern nderungen in der Wartungsphase uerste Vorsicht um die Ziele der Anwender(innen) nicht zu gefhrden. Hug benden sich unterschiedliche Versionen der Software gleichzeitig in der Entwicklungs- und Wartungsphase. So kann man die greren Freiheiten in der Entwicklungsphase nutzen und gleichzeitig die Anwender(innen) untersttzen, die noch mit lteren Software-Versionen arbeiten. Die Anwendungsphase der Software deckt sich im Groen und Ganzen mit der Wartungsphase. Software, die nicht mehr gewartet wird, wird nach wenigen Jahren kaum mehr ezient nutzbar sein, da sich die Einsatzbedingungen meist rasch ndern. Dann ist das Ende des Softwarelebenszykluses erreicht, und Anwender(innen) mssen auf andere Software umsteigen. 1.6.2 Ablauf und Werkzeuge der Programmierung Programmierung im weiteren Sinn umfasst neben der Implementierung groe Teile des Entwurfs, der Verikation und des Testens. Zur Klarstellung, dass wir es mit der Programmierung im weiteren Sinn zu tun haben, verwenden wir auch den Begri Programmkonstruktion. Beim Programmieren wiederholen wir zyklisch immer wieder folgende Schritte: Planen: Zuerst legen wir uns einen Plan zurecht, was im aktuellen Durchlauf erreicht werden soll. Editieren: Darunter verstehen wir das Schreiben oder ndern von Programmcode mittels eines Editors. bersetzen: Wenn erforderlich verwenden wir den Compiler und weitere
68
1.6 Softwareentwicklung
Werkzeuge zur Erzeugung ausfhrbaren Codes. Falls der Compiler Fehlermeldungen liefert, gehen wir zurck zum Planen und Editieren. Testen: In jedem Zyklus mssen Testflle durchlaufen werden um festzustellen, ob und inwieweit die Ziele erreicht wurden und welche Fehler noch vorhanden sind. Es ist durchaus mglich, dass von den nderungen auch Programmteile betroen sind, von denen wir das nicht erwartet haben. Wenn wir nach ausgiebigem Testen keine Fehler nden und die Software vollstndig ist, sind wir fertig. Bei greren Programmen tritt dieser Fall aber so gut wie nie ein. Debuggen: Ein Bug (auf deutsch Kfer, Wanze, Laus) ist eine umgangssprachliche Bezeichnung fr einen Programmierfehler. Beim Debuggen versuchen wir die Ursache fr ein unerwnschtes Verhalten des Programms, also einen Fehler zu nden. Dabei gewonnenes Wissen bentigen wir zur Planung des weiteren Vorgehens. Zahlreiche Entwicklungswerkzeuge untersttzen uns bei der Konstruktion von Programmen. Unter einem solchen Werkzeug verstehen wir spezielle Software, die entweder (hnlich einem Hammer oder einer Zange) einen weiten Anwendungsbereich im Bereich der Softwareentwicklung hat oder nur fr ganz spezische Aufgaben einsetzbar ist. Mit diesen Werkzeugen kommen wir sicher in Berhrung: Editor: Der Editor ist ein universell einsetzbares Werkzeug zum Lesen und Editieren (Schreiben oder ndern) beliebiger Texte. Zum Editieren von Programmen verwenden wir berwiegend spezielle Editoren, welche die Syntax unserer Programmiersprache kennen und beispielsweise Zeilen automatisch entsprechend einem fr die Sprache typischen Stil einrcken, auf noch oene Klammern hinweisen und ber Farben oder Schriftarten syntaktische Sprachelemente hervorheben (Syntax-Highlighting). Diese Fhigkeiten sind beim Programmieren und Lesen von Programmen oft sehr hilfreich. Wenn der vom Editor erwartete Programmierstil jedoch nicht mit dem tatschlichen Programmierstil bereinstimmt, kann die vom Editor stammende (falsche) Zusatzinformation sehr irritierend sein. Compiler und Interpreter: Diese wichtigen Werkzeuge haben wir bereits in Abschnitt 1.4.3 kennengelernt. Debugger: Mit Hilfe eines Debuggers knnen wir die Ausfhrung eines Programms an ausgewhlten Stellen unterbrechen, den Zustand des
69
Systems (vor allem die aktuellen Werte der Variablen) analysieren und das Programm Anweisung fr Anweisung schrittweise ausfhren. Das gibt uns einen genauen Einblick in den dynamischen Programmablauf. Allerdings ist der Umgang mit einem Debugger sehr arbeitsaufwendig, da in blichen Programmen gigantisch viele Anweisungen ausgefhrt werden. Daher versucht man das Programm statisch zu verstehen und nur dann einen Debugger einzusetzen, wenn dies zum Finden einer Fehlerursache unumgnglich ist. Integrierte Entwicklungsumgebung: Ein solches Werkzeug integriert eine ganze Reihe zusammenpassender Entwicklungswerkzeuge in einer gemeinsamen Umgebung, meist unter einer graschen Benutzeroberche. Die wichtigsten Werkzeuge wie die oben genannten sind durch wenige Mausklicks anwendbar. In der Regel werden bentigte Dateien automatisch verwaltet. Daten in diesen Dateien werden dazu verwendet, das Programmieren zu erleichtern. Beispielsweise wird man rasch auf falsch geschriebene Namen hingewiesen, oder Namen werden automatisch ergnzt, sobald deren Anfang eindeutig ist. Nicht nur Werkzeuge, sondern auch Bibliotheken untersttzen uns bei der Programmierung ganz wesentlich. Eine Bibliothek ist eine Sammlung vorgefertigter Programmteile. In der Java-Programmierung verwenden wir hauptschlich Klassen-Bibliotheken, also Sammlungen von Klassen. Wir mssen nicht alles neu programmieren, sondern haben fr die hugsten Aufgaben schon bewhrte Lsungen zur Verfgung. Die wichtigsten Bibliotheken bekommen wir als Einheit zusammen mit den wichtigsten von der Sprache abhngigen Werkzeugen (Compiler und Interpreter) geliefert. 1.6.3 Softwarequalitt Software ist nicht gleich Software. Auch bei der Entwicklung von Software mssen wir auf Qualitt achten. Generell knnen wir zwischen zwei Arten von Qualittskriterien entscheiden solche, die uns die Programmierung erleichtern, und solche, die Anwender(innen) von uns verlangen. Nicht immer sind diese Arten klar voneinander zu trennen. Das sind die wichtigsten Qualittskriterien, die in fast jeder Art von Software von Bedeutung sind: Brauchbarkeit: Die Softwarequalitt richtet sich nach den Bedrfnissen der Anwender(innen), die auf die Software angewiesen sind. Um brauchbar zu sein muss die Software mehrere Kriterien erfllen:
70
1.6 Softwareentwicklung
Zweckerfllung: Die Software erfllt nur dann ihren Zweck, wenn sie genau die Aufgaben, fr die die Software tatschlich eingesetzt wird, zufriedenstellend lsen kann. Das gilt fr alle Anwendungsflle, nicht nur die hugsten. Umgekehrt liefern unntige Eigenschaften der Software keinen Beitrag zur Zweckerfllung, knnen jedoch die Kosten erhhen und die Brauchbarkeit durch schlechtere Bedienbarkeit und greren Ressourcenbedarf negativ beeinussen. Daher sollen wir den tatschlichen Bedarf der Anwender(innen) genau analysieren und alle bentigten, aber keine unntigen Eigenschaften einbauen. Bedienbarkeit: Die Bedienbarkeit hngt davon ab, wie einfach Aufgaben mithilfe der Software lsbar sind und wie hoch der Einlernaufwand ist. Vor allem hug zu lsende Aufgaben sollen mglichst wenige Arbeitsschritte bentigen. Auerdem sollen keine unerwartet langen Wartezeiten entstehen, und die Bedienung soll intuitiv, ohne aufwendige Schulung mglich sein. Die Bedienbarkeit hngt von den Gewohnheiten und Erfahrungen der Anwender(innen) ab und kann durch genaue Analyse deren Verhaltens verbessert werden. Ezienz: Man bentigt Ressourcen wie Rechenzeit, Hauptspeicher, Massenspeicher und Netzwerkbandbreite. Software, die mit Ressourcen sparsamer umgeht, ist ezienter und von hherer Qualitt. Sie bietet mehr Potential fr knftige Erweiterungen. Auch die Ezienz der Softwareentwicklung ist fr Anwender(innen) von Bedeutung: Wenn die Entwicklung weniger Ressourcen bentigt, ist die Software billiger und rascher verfgbar. Zuverlssigkeit: Falsche Ergebnisse, Programmabstrze, fehlende oder zu spte Reaktionen auf Ereignisse etc. sollen in hochwertiger Software nicht vorkommen. Man muss sich auf die Software verlassen knnen. Zuverlssigkeit ist ein wesentlicher Kostenfaktor in der Softwareentwicklung. Deshalb strebt man nicht in jeder Art von Software denselben hohen Zuverlssigkeitsgrad an. Ein Editor ist zwar wichtig, braucht aber nicht so zuverlssig zu sein wie die Steuersoftware in einem Kernkraftwerk, Flugzeug oder Auto, wo Fehler Menschenleben kosten knnen. Absolute Zuverlssigkeit kann nie garantiert werden. Zur Erhhung der Zuverlssigkeit setzt man auf Folgendes: Bewhrtheit: Software, die sich ber einen langen Zeitraum praktisch bewhrt hat, ist zuverlssiger als neue, nur wenig geteste-
71
te Software. Wir knnen die Qualitt erhhen, indem wir unter realistischen Bedingungen ausgiebig testen. Allerdings knnen sich auch in bewhrter Software in auergewhnlichen Situationen immer wieder neue Fehler zeigen. Beispielsweise funktioniert eine Steuersoftware jahrelang problemlos, aber versagt beim ersten Auftreten eines ungewhnlichen Strfalls vllig. Auch in der Softwareentwicklung kommt es auf Bewhrtheit an. Man setzt bewhrte Techniken und Methoden ein und nutzt einschlgige Erfahrungen von Entwicklungsteams. Formale Korrektheit: Wenn es auf hohe Zuverlssigkeit ankommt, knnen formale Korrektheitsbeweise das Vertrauen steigern. Der Einsatz komplexer formaler Methoden (abseits der blichen, vom Compiler und hnlichen Werkzeugen durchgefhrten berprfungen) ist jedoch aufwendig und teuer. Beweisbar sind nur klar bestimmte formale Aussagen, die auf einer Reihe von Annahmen beruhen. Es kommt vor, dass eine Annahme in einer unerwarteten Situation verletzt ist, oder ein Fehler in einem Bereich auftritt, der durch die formale Aussage nicht abgedeckt ist. Fehler knnen also dennoch auftreten, selbst wenn alle Beweise korrekt durchgefhrt wurden. Fehlerresistenz: Fehler kann man nicht gnzlich ausschlieen, aber man kann deren Auswirkungen mildern. Ein entdeckter Fehler ist bei weitem nicht so schlimm wie ein verborgener. Beispielsweise erkennt man verletzte Annahmen durch assertAnweisungen wie in Abschnitt 1.5.4. Auf einen entdeckten Fehler kann man reagieren, indem man Anwender(innen) darauf hinweist oder die Aufgabe anders lst. In sicherheitskritischen Systemen berechnet man wichtige Werte manchmal mehrfach auf mehreren Rechnern mit unterschiedlichen Algorithmen. Man betrachtet nur bereinstimmende Werte als zuverlssig. Wartbarkeit: Die Wartung von Software ist oft viel teurer als deren Entwicklung und leichte Wartbarkeit damit von groer Bedeutung. Gut wartbare Software ist auch fr Anwender(innen) von hherer Qualitt, da in der Wartungsphase notwendige nderungen rascher und zuverlssiger erfolgen knnen. Folgende Faktoren spielen eine Rolle: Einfachheit: Ein einfaches Programm ist natrlich leichter und zuverlssiger nderbar als ein kompliziertes. Wir versuchen daher, alle Programme so einfach wie mglich zu halten. In der Pra-
72
1.6 Softwareentwicklung
xis werden Programme rasch kompliziert, wenn wir nachtrglich Code zur Behandlung irgendwelcher Sonderflle hinzufgen mssen. Am einfachsten bleiben Programmteile, in denen wir bereits zu Beginn alle Eventualitten bercksichtigt und klar und bersichtlich ausgedrckt haben. Man erreicht Einfachheit nur mit viel Aufwand, Erfahrung und Voraussicht. Lesbarkeit: Es soll einfach sein, durch Lesen des Programmcodes die Zusammenhnge im Programm zu verstehen. Die Lesbarkeit hngt vom Programmierstil ab, dieser wieder von der Erfahrung. Lokalitt: Der Eekt jeder Programmnderung soll auf einen kleinen Programmteil beschrnkt bleiben. Nicht-lokale bzw. globale Eekte sind nur schwer erkennbar und fhren daher leicht zu Fehlern. Objektorientierte Sprachen bieten einige Mglichkeiten, die uns dabei untersttzen, nderungen lokal zu halten. Faktorisierung: Die Zerlegung eines Programms in kleinere Einheiten mit zusammengehrigen Eigenschaften nennt man Faktorisierung (Factoring). Wenn es mehrere gleiche Programmteile gibt, soll man diese zu einer Einheit zusammenfhren. Darin enthaltene Fehler brauchen danach nur mehr an einer Stelle ausgebessert zu werden, nicht an mehreren schwer zu ndenden Stellen. Viele Formen der Abstraktion wie Objekte, Funktionen und hnliches helfen dabei, die Faktorisierung zu verbessern. Eine gute Faktorisierung hat positive Auswirkungen auf die Einfachheit (berschaubare abstrakte Maschinen), Lesbarkeit (verstndliche Namen von Klassen und Methoden) und Lokalitt (durch Kapselung zusammengehriger Variablen und Methoden in einem Objekt). Natrlich wollen wir stets qualitativ hochwertige Software produzieren. Allerdings hat Qualitt auch einen Preis und kann die Softwarekosten unter Umstnden explodieren lassen. Wir mssen darauf achten, in welchem Bereich es sich auszahlt, wieviel in welche Art von Qualitt zu investieren um insgesamt den grten Nutzen daraus zu ziehen. 1.6.4 Festlegung von Softwareeigenschaften Wir mssen festlegen, welche Eigenschaften wir von unserer Software erwarten. Die Form der Festlegung ist von Bedeutung. Sie bestimmt, wie die berprfung der Software erfolgen kann.
73
Informelle Beschreibung: Beschreibungen in Form eines informellen Textes erfordern keine Spezialkenntnisse. Alle gewnschten Eigenschaften sind ausdrckbar. Die Przision ist jedoch problematisch. Eine zu vage Beschreibung lsst unerwnschte Interpretationen zu, whrend eine przise Beschreibung sehr umstndlich und nur schwer lesbar ist. Formale Verikationen sind auf dieser Basis nicht mglich. Auch beim Testen ergibt sich gelegentlich die Schwierigkeit, dass nicht klar ist, was in einem bestimmten Fall erwartet wird. Anwendungsflle: Eine spezielle Form der informalen Beschreibung legt eine Reihe konkreter Anwendungsflle (Use-Cases ) fest. Fr jeden Anwendungsfall beschreibt man genau, was Anwender(innen) eingeben und welche Ergebnisse sie erwarten. Anwendungsflle ergeben sich aus der Beobachtung knftiger Anwender(innen). Die Anwendungsflle knnen leicht in Testflle abgebildet werden. Wenn das Programmverhalten in manchen Situationen durch keinen Anwendungsfall beschrieben ist, trit man sinnvolle Annahmen. Die Beschreibung ist berwiegend informell, mit allen Vor- und Nachteilen. Testflle: Man kann das gewnschte Verhalten eines Programms auch direkt ber Testflle spezizieren. Eigentlich stellt man dabei nur Anwendungsflle in Form von Testfllen dar. Diese Darstellung ist in gewissem Sinne formal. Hug gibt man, wo dies sinnvoll erscheint, keine genauen Testwerte vor, sondern nur Wertebereiche, aus denen beim tatschlichen Testen zufllig Werte gewhlt werden. Damit kann man das Problem reduzieren, dass man niemals alle mglichen Flle testen oder ber Testflle spezizieren kann. Formale Spezikation: Nur eine formale Spezikation erlaubt formale Verikation. Leider braucht man spezielles Expertenwissen sowohl fr die Erstellung als auch Verwendung. Der Umgang mit formalen Spezikationen ist meist viel aufwendiger als der mit informellen Spezikationen. Daher sind formale Spezikationen nur dort sinnvoll, wo formal veriziert wird. Vor allem ber Testflle und formale Spezikationen, aber auch in Programmcode kann man nicht alles ausdrcken, was man gerne spezizieren mchte. Wir unterscheiden zwei Arten von Eigenschaften: Funktionale Eigenschaften (Functional-Properties) lassen sich im Groen und Ganzen in jeder Form von Spezikation
74
ausdrcken. Diese Eigenschaften beziehen sich darauf, welche Ergebnisse von Berechnungen fr bestimmte Daten erwartet werden. Das entspricht den Ergebnissen von Funktionsanwendungen auf Daten. Nichtfunktionale Eigenschaften (Non-Functional-Properties) sind dagegen hug nur sehr schwer oder gar nicht formal zu fassen. Beispiele sind eine bestimmte geforderte Zuverlssigkeit oder Wartbarkeit der Software, oder eine einfache Bedienbarkeit, ohne genaue Vorgaben, wie diese erfolgen soll. Das gilt auch fr Zusicherungen. ber assert-Anweisungen lassen sich nur Eigenschaften ausdrcken, die auch ber Testflle ausdrckbar sind. Fr alles, was darber hinausgeht, knnen Zusicherungen nur als Kommentare formuliert werden.
75
Beim spteren wiederholten Lesen des ersten Kapitels werden sich wahrscheinlich neue Perspektiven ergeben, die beim ersten Lesen nicht zu erkennen waren. Die Bedeutung einiger Begrie wird sich erst erschlieen, wenn man sie in die tgliche Kommunikation mit Kolleg(inn)en einbaut. Das erste Kapitel spricht einige fr die praktische Programmierung scheinbar belanglose Themen an. So werden etwa Hardware-Architekturen und Maschinensprachen behandelt, eine Meta-Sprache zur Beschreibung der Syntax vorgestellt und der Lambda-Kalkl eingefhrt. Diese Teile des Skriptums erfllen einen auf den ersten Blick nicht sofort ersichtlichen Zweck: Beim Programmieren muss man auf eine gewisse abstrakte und formale Weise denken. Das Erlernen dieser Denkweise ist wesentlich schwieriger als das Erlernen der Syntax und Semantik einer Sprache. Hier wurden einige Systeme vorgestellt, die Lernwillige aus verschiedenen Perspektiven an diese Denkweise heranfhren. Menschen mit einer ausgeprgten mechanischen Vorstellungskraft knnen aus der Realisierung der Hardware und aus einer langsamen Erhhung der Abstraktion ber Maschinen-Modellen erkennen, wie man beim Programmieren denken kann. Hingegen werden Menschen, die von Haus aus eine abstraktere Denkweise mitbringen, eher durch die Meta-Sprache zur Beschreibung der Syntax und die vollkommen von einer praktischen Realisierung losgelsten Reduktionen von Ausdrcken an die notwendige Denkweise herangefhrt. Es wird das abstrakte Denkvermgen geschult und eine Voraussetzung dafr geschaen, Programme sowohl auf dynamische als auch statische Weise verstehen zu knnen. Das geht leichter, wenn man die Feinheiten einer umfangreichen Programmiersprache wie Java vernachlssigen kann. Um gute Programme schreiben zu knnen, braucht es neben einigem Wissen viel praktische bung und Erfahrung. Vieles muss man ausprobieren. Dann werden sich erste Erfolge beim Programmierenlernen bald einstellen. Mit der Erfahrung entstehen Bilder abstrakter Strukturen automatisch im Kopf, ohne dass man alle Details kennen und alle dynamischen Ablufe durchspielen muss. Mit dem abstrakten Denkvermgen steigt die Geschwindigkeit beim Programmieren. Man lernt, hochkomplexe Zusammenhnge unter Zeitdruck zu verstehen und in Programme umzusetzen. Neben abstraktem Denkvermgen braucht und entwickelt man dafr auch eine hohe Konzentrationsfhigkeit. Wir mssen in Teams zusammenarbeiten. Auch das muss man lernen. Konkret muss man eine fachspezische Sprache entwickeln und fast tglich in der Kommunikation innerhalb des Teams anwenden um Gedanken im Zusammenhang mit der Programmierung rationell auszutauschen. Man
76
lernt, die Gedanken anderer Teammitglieder zu antizipieren. Erst dann kann man zusammen ezienter Programme konstruieren als alleine. Innerhalb jedes Teams wird sich eine andere Aufgabenverteilung ergeben, idealerweise abhngig von den Strken der einzelnen Teammitglieder. Unterschiedliche Strken fhren zu individuellen Herangehensweisen an das Programmierenlernen. Manchmal sieht man rasch Fortschritte. Gelegentlich scheint nichts weiterzugehen, bevor man pltzlich einen groen Schritt vorwrts macht. Lernfortschritte sind kaum planbar. Die wichtigste Empfehlung lautet, sich intensiv mit der Programmierung auseinanderzusetzen. Das betrit die theoretische Auseinandersetzung mit den angesprochenen Themen ebenso wie das praktische Ausprobieren und Experimentieren mit Beispielen, einschlielich Assemblerprogrammen, Syntaxbeschreibungen und Lambda-Reduktionen. Man sollte versuchen, auch solche Querverbindungen zwischen Begrien herzustellen, die nicht explizit im Text angefhrt sind. Es empehlt sich auch, Beispiele abzundern und zu schauen, was die nderungen bewirken. Konkret knnte man versuchen, den Wertebereich fr das Zahlenratespiel auf 0 bis 9 einzuschrnken bzw. auf -50 bis 150 zu erweitern, oder statt einer Zahl ein einzelnes Bit bzw. ein Bitmuster in einem Wort zu erraten. 1.7.1 Kontrollfragen Was ist eine Deklaration bzw. Denition ? Wozu braucht man Variablen und was haben Variablen mit dem Speicher eines Computers gemeinsam? Was haben Nachrichten mit Methoden und Funktionen zu tun, und wie hngen diese Begrie mit Prozessor-Befehlen zusammen? Wodurch unterscheidet sich ein formaler Parameter von einem aktuellen Parameter bzw. Argument ? Bestehen diese Unterschiede auf syntaktischer oder semantischer Ebene? Woran erkennt und wozu braucht man Kommentare ? Was sind Klassen und Objekte, und welche Beziehungen gibt es zwischen diesen beiden Begrien? Was macht ein Konstruktor und wie unterscheidet er sich von einer Methode?
77
Was versteht man unter dem Initialisieren eines Objekts oder einer Variablen? Welche Beziehungen bestehen zwischen den Begrien Datenkapselung, Data-Hiding und Datenabstraktion ? Was ist eine Schleifenbedingung, ein Schleifenzhler, ein Schleifenrumpf und eine Iteration ? Wie hngen bedingte Anweisungen mit Programmzweigen und Fallunterscheidungen zusammen? Ermitteln Sie experimentell, wie viele Versuche Sie typischerweise brauchen um eine Zufallszahl mittels Zahlenraten zu erraten. Was ist ein Bit, ein Byte und ein Wort, und warum hat ein Kilobyte 1024 Byte? Wie lautet Ihre (negierte) Matrikelnummer als Binrzahl? Was ist ein Algorithmus, und wodurch unterscheidet sich ein Algorithmus von einem Programm bzw. einer Methode? Aus welchen Teilen setzt sich die Von Neumann-Architektur zusammen? Wodurch unterscheidet sie sich von der Harvard-Architektur ? Welche Schritte werden von einer Von Neumann-Architektur stndig wiederholt ausgefhrt, welche von einem Interpreter? Was ist eine Assembler-Sprache ? Warum verwenden wir abstrakte Maschinen wie die JVM? Was sind Berechnungsmodelle und wie hngen sie mit Programmiersprachen zusammen? Welche Eigenschaften von Objekten helfen dabei, Objekte als abstrakte Maschinen zu betrachten? Was ist eine Komponente oder Modul? Wie stehen die Begrie Architektur, Implementierung und Schnittstelle sowohl in der Hardware als auch in der Software zueinander?
78
Knnen Sie Beziehungen zwischen den Begrien Objektzustand, Objektverhalten und Objektidentitt einerseits und Daten, Algorithmen und Umgebung andererseits erkennen? Was bedeuten Syntax, Semantik und Pragmatik, und wozu verwendet man eine Grammatik ? Welche der folgenden Ausdrcke (je ein Ausdruck pro Zeile) entsprechen der Grammatik in Abbildung 1.14? if(x);else; for(i=1;i=2;i=3) return x; if(x)while(y)for(i=1;;)if(x){{;;}}else{{y;}};; while(i=1;;)for(y)return x; Wozu dienen deklarierte Typen ? Was unterscheidet einen Compiler von einem Interpreter ? Was versteht man unter Quell-, Ziel- und Zwischencode ? Wie kommen Java-Programme blicherweise zur Ausfhrung (bersetzung und/oder Interpretation)? Wofr steht die Abkrzung JIT ? In welchem Zusammenhang ist sie von Bedeutung? Wie wirken sich Laufzeitfehler im Vergleich zu Fehlermeldungen und Warnungen des Compilers aus? Wann ist etwas statisch und wann dynamisch ? Wann spricht man von statischer und wann von dynamischer Semantik einer Programmiersprache? Was ist eine Programmoptimierung und wer macht sie? Was unterscheidet formale von natrlichen Sprachen? Was versteht man unter Abstraktion, und welche Arten davon haben wir schon kennengelernt? Wodurch unterscheiden sich reine Funktionen von Methoden, Prozeduren und Routinen ?
79
Welche Programmierparadigmen haben wir unterschieden? Wodurch zeichnen sie sich jeweils aus? Wozu dient der Lambda-Kalkl ? Welche Eigenschaften hat er, und warum ist er in der Informatik so bedeutend? Was ist ein -Ausdruck ? Welche Formen von -Ausdrcken kann man unterscheiden? Was versteht man unter freien bzw. gebundenen Variablen und was unter Ersetzung ? Was bewirkt eine -Konversion bzw. - und -Reduktion ? Wann ist ein -Ausdruck in Normalform ? Welche Normalformen haben die folgenden zwei -Ausdrcke? (u.(v.(w.(v ((u v ) w))))) (x.(y.y )) (u.((v.(u (v v ))) (v.(u (v v ))))) e Was besagt die Unentscheidbarkeit des Halteproblems ? Was sind Zusicherungen ? Wie knnen wir sie ausdrcken? Wodurch unterscheiden sich Vor- von Nachbedingungen ? Was versteht man unter der Entwicklung, Wartung und Anwendung von Software? Welche Entwicklungsschritte werden unterschieden? Was versteht man unter den Begrien zyklischer Softwareentwicklungsprozess und schrittweise Verfeinerung ? Welche Schritte fhrt man beim Programmieren zyklisch wiederholt aus? Welche Softwareentwicklungswerkzeuge kennen Sie? Was ist eine Klassen-Bibliothek ? Wovon hngt die Qualitt von Software ab? Wie kann man erwartete Eigenschaften von Software festlegen? Was unterscheidet funktionale von nichtfunktionalen Eigenschaften ?
80
2 Grundlegende Sprachkonzepte
In diesem Kapitel wird die Programmierung konkret. Es wird Detailwissen ber wichtige Sprachkonzepte in Java vermittelt. Dieses Wissen sollte ausreichen um selbst kleine Programme zu schreiben.
81
2 Grundlegende Sprachkonzepte
Statt von Objekten sprechen wir auch hug von Daten und Werten als im Programm auftretenden Berechnungsgren.2 Fr unseren Zweck reicht es, wenn wir diese Begrie als gleichbedeutend betrachten, obwohl es zwischen ihnen feine Unterschiede gibt. So sprechen wir eher von Objekten, wenn wir eine abstrakte Sichtweise hervorheben wollen, Werten, wenn wir konkrete einzelne Werte wie beispielsweise Zahlen oder Variableninhalte (siehe unten) meinen, Daten, wenn wir grere oder unbestimmte Ansammlungen von Werten meinen. Daneben verwenden wir viele weitere Begrie fr Werte bestimmter Arten, beispielsweise Wahrheitswerte und (natrliche, ganze, etc.) Zahlen. Betrachten wir ein typisches Beispiel fr einen Algorithmus: Der Euklidische Algorithmus zur Berechnung des grten gemeinsames Teilers (GGT) zweier natrlichen Zahlen wird vom griechischen Mathematiker Euklid (ca. 360 bis 280 v. Chr.) im Buch 7 seiner Abhandlung Die Elemente beschrieben. In Lehrbchern ber Programmierung taucht er oft als Beispiel in unterschiedlichen Formulierungen auf. Der GGT wird beim Krzen von Brchen bentigt: Ein Bruch lsst sich krzen, indem man Zhler und Nenner durch deren GGT dividiert. Der Algorithmus von Euklid lautet (nach einer deutschen bersetzung):
Der Algorithmus von Euklid.
. . . Wenn N Teiler von M ist, ist N , weil auch Teiler von sich selbst, gemeinsamer Teiler. N ist dann auch der grte Teiler, denn grer als N kann ein Teiler von N nicht sein. Wenn N nicht Teiler von M ist, subtrahiert man, von den beiden Zahlen M und N ausgehend, immer die kleinere von der greren bis die entstandene Zahl Teiler der ihr vorhergehenden ist, der dann der grte gemeinsame Teiler von M und N ist. . . . In diesem Text erkennt man zwei Variablen M und N , die fr Objekte stehen (oder anders gesagt, die Objekte enthalten), mit welchen der Algorithmus arbeitet. Jedes dieser Objekte ist eine natrliche Zahl. Zu Beginn
2
Datum als Einzahl von Daten wird wegen der allgemeinen Verwendung im Sinn von Kalenderdatum nur selten benutzt. Stattdessen sprechen wir eher von einem Wert oder manchmal auch Datenwert.
82
enthalten M und N die beiden Zahlen, deren GGT berechnet werden soll. Durch bestimmte Handlungen (wir nennen sie Operationen) werden die Variablen M und N verndert (z.B. subtrahiert man . . . die kleinere von der greren ). Genaugenommen werden die Objekte, die in den Variablen enthalten sind, durch andere Objekte ersetzt. Am Ende enthalten beide Variablen dasselbe gesuchte Objekt, den grten gemeinsamen Teiler. Die obige Formulierung des Algorithmus lsst sich so umformen, dass die einzelnen Schritte klarer ersichtlich sind:
Struktur des Algorithmus.
Solange M ungleich N ist, wiederhole: Wenn M grer als N ist, dann: Ziehe N von M ab und weise das Ergebnis M zu. Sonst (das heit, N ist grer als M ): Ziehe M von N ab und weise das Ergebnis N zu. (Nun ist M gleich N ) M bzw. N ist der grte gemeinsame Teiler. In dieser Beschreibung des Algorithmus sieht man einen zeitlichen Ablauf. Man beginnt oben und fhrt die Schritte nacheinander in der gegebenen Reihenfolge durch, bis man am Ende das Ergebnis erhlt. Zwei verschiedene Arten von Anweisungen sind deutlich unterscheidbar: Operationen mit Objekten: Die beiden Variablen M und N sind nicht konstant, sondern stehen zu unterschiedlichen Zeitpunkten fr unterschiedliche Objekte. Die Anweisungen, die die Inhalte von M und N verndern (weise das Ergebnis . . . zu), heien Zuweisungen. Durch eine Zuweisung wird der alte Inhalt von M bzw. N durch einen neuen Inhalt ersetzt. Im Falle der Berechnung des GGT ist der neue Inhalt die Dierenz der bisherigen Inhalte von M und N . Man spricht von Variablen, weil die Inhalte sich im Laufe der Zeit ndern knnen, also variabel sind. Variablen betrachten wir in Abschnitt 2.1.2 genauer. Steuerung des Programmusses: Die obige Beschreibung des Algorithmus geht davon aus, dass eine Handlung nach der anderen ausgefhrt wird und nicht mehrere gleichzeitig. Diese Hintereinanderreihung von Handlungen nennt man auch Sequenz. Zustzlich gibt es Anweisungen, die diesen sequentiellen Ablauf steuern, nmlich die Auswahl aufgrund einer Fallunterscheidung zwischen zwei alternativen Handlungen, auch Selektion genannt: Wenn . . . , dann: . . . Sonst . . . .
83
2 Grundlegende Sprachkonzepte
Die beiden alternativen Anweisungen sind gegenber der Selektionsanweisung eingerckt dargestellt um deutlich zu machen, dass sie nur unter der darber angegebenen Bedingung durchgefhrt werden. Weiters deuten die Worte Solange M ungleich N ist, wiederhole: darauf hin, dass die darunterstehenden Anweisungen in einer Schleife immer wieder ausgefhrt werden sollen, solange die Schleifenbedingung (M ungleich N ) erfllt ist. Auch hier werden alle zu wiederholenden Anweisungen eingerckt dargestellt. Jeder Durchlauf durch die Schleife heit Iteration. Es kann sein, dass die Schleifenbedingung gleich zu Beginn nicht mehr erfllt ist und keine einzige Iteration erfolgt, es knnen aber auch viele Iterationen ntig sein, bevor M und N gleiche Objekte enthalten. Die Negation der Schleifenbedingung (also M gleich N ) heit auch Abbruchbedingung der Schleife. In unserem Beispiel ist auch erluternder Text (in Klammern) in der Formulierung des Algorithmus vorhanden. Solche Kommentare gehren eigentlich gar nicht wirklich zum Algorithmus, sondern untersttzen nur die Lesbarkeit. Man knnte sie ersatzlos streichen. So ist durch das Wort Sonst schon klar, in welchen Fllen die danach folgende Anweisung durchzufhren ist. Die Kommentare sorgen nur dafr, dass wir beim Lesen die entsprechenden Bedingungen gleich im Kopf haben. Es gibt auch Algorithmen, bei denen ein Problem durch parallel ablaufende Handlungen gelst wird. Auf oft recht komplizierte parallele Algorithmen wollen wir hier jedoch nicht nher eingehen. Die obige umgangssprachliche Beschreibung des Euklidischen Algorithmus ist ausreichend um die Schritte fr beliebige Startwerte durchzufhren. Probieren Sie den Algorithmus fr die Startwerte M = 9 und N = 6 aus. Sie bentigen zwei Zuweisungen, bis M und N die gleiche Zahl 3 enthalten, die dem GGT von 9 und 6 entspricht. Der Algorithmus lsst sich ziemlich direkt in Java formulieren. In Listing 2.1 nden wir ein Java Programm, das den GGT von 1027 und 395 berechnet und auf dem Bildschirm ausgibt. Generell besteht ein Programm in einer imperativen Programmiersprache aus einer Folge von Anweisungen. Die Anweisungen knnen von einer (abstrakten) Maschine ausgefhrt werden. So stehen in den Zeilen 7, 8, 12 und 14 spezielle Anweisungen, die Inhalte von Variablen neu setzen; das sind Zuweisungen.
Umsetzung in Java.
84
Listing 2.1: Der Euklidische Algorithmus in Form eines Java Programms 1 public class Euklid1 { 2 public static void main(String[] args) { 3 4 int m; 5 int n; 6 7 m = 1027; 8 n = 395; 9 10 while (m != n) 11 if (m > n) 12 m = m - n; 13 else // es gilt n > m 14 n = n - m; 15 16 // nun gilt m == n 17 [Link](m); 18 19 } 20 }
In fast allen Zeilen sind auch Ausdrcke als Teil einer Anweisung enthalten. Ausdrcke sind alle Konstrukte, die ein Objekt als Ergebnis liefern. Beispielsweise ist m - n in Zeile 12 ein Ausdruck, der die Dierenz zwischen m und n liefert. Die Zahl 1027 in Zeile 7 ist ebenfalls ein einfacher Ausdruck, nmlich ein Literal. Literale beschreiben konkrete Werte direkt im Gegensatz zu Ausdrcken, die Ergebnisse von Berechnungen liefern. Der eigentliche Algorithmus wird durch die Zeilen 10 bis 14 dargestellt. Man kann erkennen, wie die Anweisungen, die wir oben umgangssprachlich beschrieben haben, in Java formuliert sind. Zeile 17 enthlt eine Anweisung, die das Ergebnis m am Bildschirm ausgibt. Kommentare zwischen // und dem Zeilenende dienen nur dazu, die Lesbarkeit des Programms zu verbessern. Ein Kommentar fhrt keine Anweisung aus. Die Zeilen 4 bis 8 enthalten die Deklarationen der bentigten Variablen (eine Erklrung folgt in Abschnitt 2.1.2) und die Zuweisungen der Anfangszahlen. Die Zeilen 1 und 2 gehren zusammen mit den Zeilen 19 und 20 zur Programmorganisation, die bentigt wird, damit sich das Programm entsprechend der Java-Spezikation bersetzen und ausfhren lsst. Die Bedeutung dieser Zeilen wird in den folgenden Abschnitten er-
85
2 Grundlegende Sprachkonzepte
klrt. Der Name des Programms steht nach dem Schlsselwort class in Zeile 1 und kann vom Programmierer selbst festgelegt werden. Um das Programm auszufhren (dringend empfohlen) mssen wir darauf achten, dass der Programmtext in einer Datei namens [Link] gespeichert ist. Der Dateiname muss mit dem Klassennamen bereinstimmen. 2.1.2 Variablen und Zuweisungen Eine Variable steht fr einen im Lauf der Zeit nderbaren Wert. Sie enthlt zu jedem Zeitpunkt einen Wert, den Wert der Variablen. Man kann die Variable als benannten Speicherbereich oder Behlter verstehen, der Platz fr genau einen Wert hat. Dieser Wert kann durch eine Zuweisung mit einem neuen Wert berschrieben werden. In Abbildung 2.2 hat die Variable m den Wert 1027 und die Variable n den Wert 395. Der Name einer Variablen wird stellvertretend fr deren Wert benutzt. Wenn wir zum Beispiel sagen: berechne die Dierenz von m und n, dann meinen wir eigentlich: berechne die Dierenz der Werte von m und n, also 1027 395.
m
1027
n
395
Eine Variable in einer Programmiersprache wie Java unterscheidet sich von einer Variable in der Mathematik, wo es keine zeitliche Dimension gibt. So beschreibt die mathematische Gleichung U = 2r den funktionalen Zusammenhang zwischen zwei Variablen, das heit, wie man U bestimmen kann, sobald r feststeht (oder umgekehrt). Die beiden mathematischen Variablen haben zu keinem Zeitpunkt einen bestimmten Wert, sondern sind Platzhalter fr Elemente von Denitionsmenge bzw. Bildmenge der Funktion. Auch in rein funktionalen Programmiersprachen sowie dem in Abschnitt 1.5.2 vorgestellten Lambda-Kalkl sind Variablen reine Bezeichner fr Werte, die sich im Lauf der Berechnung nicht ndern. In Java und anderen imperativen Programmiersprachen ist eine Variable dagegen ein benannter Speicherbereich, der eine bestimmte Anzahl
86
von Bytes belegt, um darin einen Wert zu speichern. Abbildung 2.3 zeigt eine schematische Darstellung eines Speicherbereichs, in dem Variablen angelegt sind. Die Typen der Variablen sind nicht in den Speicherzellen festgehalten, aber dem Compiler und Interpreter bekannt. Typen legen die Anzahl der bentigten Bytes fest und bestimmen, wie die Bitmuster an den entsprechenden Stelle im Speicher zu interpretieren sind. Fr die hier dargestellten Variablen m und n vom Typ int mit den Adressen 260 bzw. 264 werden jeweils 4 Bytes bentigt. Das Bitmuster in der Variablen m entspricht dem Wert 1027, das in n dem Wert 395. Wre n jedoch eine Fliekommazahl vom Typ float, htte sie bei demselben Bitmuster den Wert 1.439 1042 . Java stellt durch starke Typisierung sicher, dass es zu keinen Verwechslungen der Typen kommen kann.
Eigenschaften.
Wert: Manchmal spricht man auch vom R-Wert, eine Bezeichnung, die daher rhrt, dass die Variable diesen Wert reprsentiert, wenn Sie auf der rechten Seite einer Zuweisung steht. Zuweisungen werden in Krze besprochen. Der (R-)Wert ist also das, was wir bekommen, wenn eine Variable als Teil eines Ausdruck ausgewertet wird. In Abbildung 2.2 ist der (R-)Wert von m die Zahl 1027. Name: Er wird manchmal auch Bezeichner (Identier ) genannt. Speicheradresse: An dieser Adresse wird der Wert der Variablen gespeichert. Man bezeichnet die Adresse auch als L-Wert. Beispielsweise wird durch eine Zuweisung m = n der (R-)Wert der Variablen n an die Speicheradresse der Variablen m geschrieben, die links steht daher die Bezeichnung L-Wert fr die Adresse. Der alte (R-)Wert von m geht durch die Zuweisung verloren. In einigen Programmiersprachen kann man eine Variable explizit ber ihren L-Wert ansprechen (Liefere die Adresse der Variablen m), was in Java jedoch nicht mglich ist. In Java bleibt uns die Adresse der Variablen verborgen, sodass sich die Adresse ndern (also innerhalb des Speichers verschieben) kann, ohne die Konsistenz der Daten zu zerstren. Typ: Er schrnkt die zulssige Wertemenge ein und bestimmt, wie das Bitmuster in den Speicherzellen der Variablen zu interpretieren ist. Lebensdauer: Eine Variable existiert nur im Zeitraum vom Reservieren ihres Speicherbereichs (Memory-Allocation ) bis zu dessen Freigabe
87
2 Grundlegende Sprachkonzepte
(Memory-Deallocation ). So lebt eine lokale Variable einer Methode nur solange die Methode ausgefhrt wird. Gltigkeitsbereich: Variablen sind im Allgemeinen nicht im gesamten Programm gltig. Der Gltigkeitsbereich bestimmt, von wo aus im Programm der Zugri auf die Variable mglich sein kann. So sind lokale Variablen einer Methode nur innerhalb der Methode gltig. Dadurch sind Variablen nur whrend ihrer Lebensdauer zugreifbar. Sichtbarkeitsbereich: Mglicherweise ist eine Variable an einer bestimmten Programmstelle zwar gltig, aber es darf trotzdem nicht darauf zugegrien werden. Der Sichtbarkeitsbereich gibt an, von welchen Programmstellen aus ein Zugri auf die Variable erlaubt ist. Der Sichtbarkeitsbereich kann im Vergleich zum Gltigkeitsbereich strker eingeschrnkt sein, etwa durch Deklaration als private.
Deklaration.
Bevor eine Variable benutzt werden kann, muss sie deklariert werden. Bei der Deklaration wird der Name der Variablen und deren Typ vereinbart. Die Ausfhrung der Deklarationsanweisung bewirkt auch, dass der fr die Variable bentigte Speicherplatz reserviert wird. Die Zeilen 4 und 5 in Listing 2.1 sind Variablendeklarationen.
88
Der Typ int in der Deklaration int m; bedeutet, dass die Variable m ganzzahlige Werte speichern kann. Es wird also ausreichend Speicherplatz fr eine ganze Zahl reserviert. Die Speicheradresse der Variablen wird durch die Deklaration zwar festgelegt, bleibt uns aber verborgen. In Java bekommen manche Variablen gleich bei der Deklaration einen Wert, der vom Typ abhngt. Im Falle von int wre das der Anfangswert 0. Das gilt jedoch nicht fr sogenannte lokale Variablen wie m und n in Listing 2.1, die in einer Methode wie main deklariert wurden. Solchen Variablen muss man einen Wert zuweisen, bevor man sie verwenden kann. Die genaue Stelle einer Deklaration im Programm bestimmt den Gltigkeitsbereich, also jene Programmteile, von wo aus ein solcher Zugri ermglicht werden kann. Nach der Deklaration (und Zuweisung eines Anfangswertes) kann auf die Variable ber ihren Namen zugegrien werden. Wir werden bei den Beschreibungen der jeweiligen Sprachkonstrukte erklren, wo dort deklarierte Variablen gltig sind. Oft entspricht der Sichtbarkeitsbereich (also der Bereich, in dem der Zugri tatschlich mglich ist) dem Gltigkeitsbereich. Wir werden erst in Kapitel 3 sehen, wie wir den Sichtbarkeitsbereich beeinussen knnen. In der Syntax der Variablendeklaration wird als erstes Wort der Name des Typs angegeben, und nach einem Leerraum folgt der Name der Variablen. Die Deklarationsanweisung wird durch einen Strichpunkt beendet. Es ist aber auch mglich, mehrere Variablen desselben Typs in einer einzigen Anweisung zu deklarieren, indem man die Variablennamen durch Kommata trennt. Folgende Deklaration ist zwar syntaktisch verschieden, aber semantisch quivalent zu den Zeilen 4 und 5 im Listing 2.1: int m, n; Eine Zuweisung gibt einer Variablen einen neuen Wert. Der Zuweisungsoperator in Java ist =. Durch die Anweisung
Zuweisung.
n = 395; wird der Variablen n der Wert 395 zugewiesen. Allgemein steht auf der linken Seite des Zuweisungssymbols der Name der Variablen, die einen neuen Wert bekommen soll, und rechts ein Ausdruck, der den Wert liefert. Der Typ des Ausdrucks muss mit dem Typ der Variablen zuweisungskompatibel sein. Wenn die Typen gleich sind, dann sind sie sicher zuweisungskompatibel zueinander. Wir werden spter noch Flle kennenlernen, in denen die Typen nicht gleich, aber trotzdem zuweisungskompatibel sind.
89
2 Grundlegende Sprachkonzepte Werte m 1027 632 237 237 79 79 von n 395 395 395 158 158 79
Die Zeilen 7, 8, 12 und 14 in Listing 2.1 enthalten Zuweisungen. Dabei legen die Zuweisungen in den Zeilen 7 und 8 die Anfangswerte (auch Initialwerte genannt) fest, fr die der GGT berechnet werden soll. Diese beiden Zuweisungen zusammen initialisieren das Programm. Die Zeilen 12 und 14 benden sich im Rumpf einer Schleife und werden im Regelfall mehrmals durchlaufen. Sie sind Teil einer Verzweigung (if-else), die bewirkt, dass immer der kleinere vom greren Wert abgezogen wird. Wenn die Anfangswerte wie hier 1027 und 395 sind, wird die Anweisung m = m - n; drei mal durchgefhrt. Wir knnen die Zustnde der Variablen beim Programmablauf mit Hilfe einer Tabelle verfolgen: Tabelle 2.4 zeigt den Zustand der Variablen nach jedem Verarbeitungsschritt fr die Startwerte 1027 und 395, die den Variablen im ersten Verarbeitungsschritt der Initialisierung zugewiesen werden. Zur syntaktischen Vereinfachung ist es in Java mglich, die Deklaration von Variablen mit deren Initialisierung zu verbinden. Beispielsweise haben die Deklarationen
Initialisierung.
int m = 1027; int n = 395; dieselbe Semantik (also dieselbe Bedeutung) wie die Zeilen 4 bis 8 in Listing 2.1 zusammengenommen. Noch krzer ist die Syntax bei gleicher Semantik, wenn wir beide Variablen in nur einer Anweisung deklarieren: int m = 1027, n = 395;
90
Zwecks einfacherer Lesbarkeit sollten wir jedoch vermeiden, zu viel in eine einzige Deklarationsanweisung zu schreiben. Einer Variablendeklaration knnen sogenannte Modier vorangestellt sein, welche die Eigenschaften der deklarierten Variablen genauer festlegen. Wie wir noch sehen werden, kann man damit beispielsweise die Sichtbarkeit der Variablen beeinussen. Gelegentlich verwendet man den Modier final, der verhindert, dass der Wert der deklarierten Variablen nach der ersten Zuweisung (Initialisierung) verndert wird: final int vier = 4; Es ist nicht mglich, den Wert 4 von vier (absichtlich oder unabsichtlich) durch eine weitere Zuweisung zu ndern. Sinnvollerweise ist eine solche Variable gleich bei der Deklaration zu initialisieren, da eine sptere Zuweisung ja nicht mehr mglich ist. 2.1.3 Datentypen Ein Datentyp oder kurz Typ bestimmt eine Wertemenge und legt die Operationen fest, die auf den Elementen dieser Menge durchgefhrt werden knnen. Die Elemente der Wertemenge heien auch Instanzen des Typs. Zum Beispiel ist die Fliekommazahl 37.6991123 eine Instanz des Typs double und die ganze Zahl 12 eine Instanz des Datentyps int. Jede Variable braucht einen Typ, damit klar ist, wieviel Platz die Variable im Speicher bentigt, also wieviele Bytes sie belegt. Weiters bestimmt der Typ auch, wie das Bitmuster in den Speicherzellen, die die Variable belegt, interpretiert werden soll. Beispielsweise liefert ein GanzzahlBitmuster als Fliekommazahl interpretiert im Allgemeinen einen falschen Wert, wie wir schon in Abschnitt 2.1.2 gesehen haben. Generell stellt ein Datentyp einen Bauplan fr eine Variable dar: Alle Variablen eines bestimmten Typs haben dieselbe Darstellung im Arbeitsspeicher, das heit, sie belegen dieselbe Anzahl von Speicherzellen und haben dieselbe Interpretation der in ihnen enthaltenen Bits. Datentypen, deren Instanzen einfache Werte sind, heien elementare Typen. Man spricht auch von einfachen oder primitiven Typen. Elementar bedeutet in diesem Zusammenhang, dass sich Werte des Typs nicht aus Werten einfacherer Typen zusammensetzen lassen, also nicht weiter zerlegbar sind. Instanzen eines elementaren Typs werden in
Elementare Typen.
91
2 Grundlegende Sprachkonzepte Typbezeichnung Bytes Wertebereich Integer-Typen: byte short int long 1 2 4 8 27 bis 27 1 215 bis 215 1 231 bis 231 1 263 bis 263 1 wie int wie int 0, 1, -1 0L, 1L, -1L Literale (Bsp.)
Fliekomma-Typen: float double Zeichen: char Wahrheitswerte: boolean 1 true, false true, false 2 alle 16-Bit-Unicode-Zeichen A, a, 3, < 4 8 ca. 3.4 1038 bis 3.4 1038 1.2F, -1.2E8F
Java direkt in einer Variablen eines entsprechenden Typs gespeichert im Unterschied zu Instanzen der weiter unten beschriebenen Referenztypen. Zu den elementaren Typen zhlen in Java vier Typen ganzer Zahlen, zwei Typen von Fliekommazahlen und je ein Typ von Zeichen und Wahrheitswerten. Tabelle 2.5 fasst diese Typen zusammen. Die vielen Typen fr Zahlen unterscheiden sich in ihrem Speicherverbrauch und dem in den zur Verfgung stehenden Bits darstellbaren Wertebereichen. Fr ganze Zahlen verwendet man in der Praxis meist int oder long. Fliekommazahlen haben bei gleicher Anzahl an Bytes einen viel greren Wertebereich als ganze Zahlen, weil diese Zahlen gerundet werden, sodass sie unabhngig vom Wertebereich nur auf wenige Stellen genau sind. Ganze Zahlen werden dagegen nicht gerundet. Details dazu folgen in Abschnitt 6.2. In der Praxis verwendet man fr Fliekommazahlen meist double. Die Operationen, die auf Werten elementarer Typen durchgefhrt werden knnen, sind arithmetische Operationen, Vergleichsoperationen (Boolesche Operationen) und Bitoperationen, die in Abschnitt 2.2 besprochen werden.
92
Die zweite Art von Datentypen sind Referenztypen. Sie heien so, weil jede Instanz davon das ist ein nicht-elementares Objekt in einem separaten Speicherbereich abgelegt wird und eine Variable nur eine Referenz auf das Objekt enthlt. Eine Referenz (auch Zeiger oder Verweis genannt) ist eine Verknpfung der Variablen mit dem Speicherbereich, die das Objekt enthlt. Die Variable selbst enthlt also nur die Speicheradresse des Objekts als R-Wert. Man sagt, die Variable referenziert das Objekt. ber die Referenz knnen wir auf das Objekt zugreifen. Es ist mglich, dass mehrere Variablen eines Referenztyps eine Referenz auf dasselbe Objekt enthalten. Wir knnen daher ber mehrere Variablen auf dasselbe Objekt im selben Speicherbereich zugreifen. Im Gegensatz dazu enthlt eine Variable eines elementaren Typs den Wert direkt, ohne Referenz. Es ist daher in Java nicht mglich, ber unterschiedliche Variablen eines elementaren Typs auf dasselbe Objekt zuzugreifen. Eine Variable, deren Typ ein Referenztyp ist, nennen wir der Einfachheit halber Referenzvariable. In Abbildung 2.6 sind zwei Referenzvariablen mit den Namen r und s sowie die referenzierten Objekte schematisch dargestellt. Belegte Speicherbereiche sind grau hinterlegt, und Pfeile reprsentieren Verweise von den Variablen zu den Objekten. Instanzen von Referenztypen (also Objekte) belegen fast immer grere Speicherbereiche als Instanzen elementarer
Referenztypen.
93
2 Grundlegende Sprachkonzepte
p
37.699112
s
ref
Objekt f i r s t t h i n g s f i r s t
Abbildung 2.7: Variable p vom Typ float versus s vom Typ String
Typen (also einfache Werte), da sie mehrere Werte gemeinsam speichern. Ein Vorteil von Referenztypen liegt auf der Hand: Um die Inhalte der beiden Variablen zu tauschen, mssen nur die Referenzen getauscht werden und nicht die Objekte selbst. Vor allem knnen mehrere Variablen auf ein und dasselbe Objekt verweisen. Das ist beispielsweise dann sinnvoll, wenn auf das Objekt von mehreren Stellen im Programm aus zugegrien werden soll. Wenn dagegen zwei Variablen eines elementaren Typs denselben Wert speichern, liegen die Werte zweimal vor. Ein Beispiel fr einen Referenztyp ist String, der Typ von Zeichenketten. Eine Zeichenkette ist eine Aneinanderreihung mehrerer Zeichen vom Typ char, die gemeinsam in einem Objekt des Typs String abgespeichert werden. In einer Variable des Typs String wird nur die Referenz auf das Objekt gespeichert. Mit der Deklaration String s; wird eine neue Referenzvariable erzeugt. Wenn wir eine Zeichenkette mit s = "first things first"; zuweisen, wird zunchst vom Ausdruck auf der rechten Seite des Zuweisungsoperators (in diesem Fall ein String-Literal ) als Wert eine Referenz auf ein Objekt mit der Zeichenkette "first things first" geliefert, und diese Referenz wird dann in der Variablen s gespeichert. Das Ergebnis ist in Abbildung 2.7 veranschaulicht und einer Variablen eines elementaren Typs gegenbergestellt. In folgendem Beispiel werden die Auswirkungen der Verwendung von Referenztypen deutlicher: String r, s; // Deklaration von zwei Variablen s = "first things first"; r = s; // Referenzen auf dasselbe Objekt
94
Objekt f i r s t t h i n g s f i r s t
s
ref
Die beiden Variablen r und s enthalten nach den Zuweisungen Referenzen auf dasselbe Objekt. Die Zeichenkette "first things first" existiert nur einmal siehe Abbildung 2.8. In Java enthalten viele Variablen gleich nach der Deklaration je nach Typ den Wert 0 oder etwas Vergleichbares. Auch Referenzvariablen enthalten gleich nach der Deklaration einen Wert, nmlich null. Diesen speziellen Wert kann man als eine Referenz auf nichts betrachten. Direkt nach der Deklaration referenzieren r und s also kein Objekt. In der JavaProgrammierung wird null hug verwendet. Man kann null an jede Referenzvariable zuweisen, unabhngig vom Typ der Variablen (solange es sich um einen Referenztyp handelt). Wir knnen beim Programmieren durch die Denition von Klassen und Interfaces neue Typen erstellen siehe Kapitel 3. Alle auf diese Weise von uns selbst eingefhrten Typen sind Referenztypen. Es ist in Java nicht mglich, selbst neue elementare Typ zu denieren.
95
2 Grundlegende Sprachkonzepte
des liefert einen Wert. Ein Wert kann aber auch von einer aufgerufenen Methode zurckgegeben werden, und der Aufruf stellt daher einen Ausdruck dar. Der Rckgabewert des Ausdrucks ist das Ergebnis der Auswertung des Ausdrucks. Im Lambda-Kalkl ist der Rckgabewert die Normalform, die wir durch Reduktion (entspricht der Auswertung) eines -Ausdrucks erhalten. Anders als der Lambda-Kalkl untersttzen Programmiersprachen wie Java eine Vielzahl an unterschiedlichen Formen von Ausdrcken. Glcklicherweise hneln die wichtigsten davon mathematischen Ausdrcken, die wir schon aus der Schule kennen, und untersttzen unsere Intuition recht gut. So ist etwa 5+2 ein Ausdruck, dessen Auswertung den Wert 7 liefert. Ausdrcke (z.B. 5 und 2) knnen durch Anwendung eines Operators (z.B. + und -) zu einem komplexeren Ausdruck (z.B. 5+2 und 5-2) verknpft werden. Dabei bernehmen die zu verknpfenden Ausdrcke (hier sind es Zahlen) die Rolle von Operanden fr diesen Operator. Jeder Ausdruck kann so als Operand wieder zu einem Bestandteil eines abermals komplexeren Ausdrucks werden, etwa (5+2)-(5-2). In Java unterscheidet man einstellige (unre) von zweistelligen (binren) Operatoren. Zustzlich gibt es einen dreistelligen (ternren) Operator, den Bedingungsoperator. Ein Beispiel fr einen einstelligen Operator ist der Vorzeichenoperator - (Minusoperator), der das Vorzeichen des nachfolgenden Operanden negiert, so wie in diesem Ausdruck: - x Fr den Compiler spielt es keine Rolle, ob zwischen - und x Leerzeichen stehen oder nicht. Der Rckgabewert von -x hat den negierten Wert der Zahl in der Variablen x. Ein Beispiel fr einen zweistelligen Operator ist der Additionsoperator, der als Ergebnis die Summe der beiden Operanden liefert. So ist im Ausdruck 5 + 2 das Symbol + der Operator, und die beiden Zahlen-Literale sind Operanden. Syntaktisch werden binre Operatoren inx hingeschrieben, was bedeutet, dass der Operator zwischen den beiden Operanden stehen muss. Auch der Zuweisungsoperator = ist ein Inx-Operator. Unre Operatoren knnen vor oder hinter dem Operanden stehen. Man spricht von Prx - bzw. Postx -Operatoren. Ein Beispiel ist der Ausdruck x++
96
in dem der Operator ++ als Postx-Operator hinter dem Operanden x steht. Der oben erwhnte unre Vorzeichenoperator - ist ein Beispiel fr einen Prx-Operator. Im Ausdruck ++x wird ++ ebenso als Prx-Operator verwendet. Die semantische Bedeutung des Symbols ++ ist als Prx-Operator verschieden von der als Postx-Operator. Es handelt sich trotz Verwendung desselben Symbols also um zwei verschiedene Operatoren. Beide Operatoren inkrementieren den Wert ihres Operanden, das heit, sie erhhen den Wert um eins. Die genauen Bedeutungen und dizilen Unterschiede werden spter erklrt. Wie in Abschnitt 2.1.2 erklrt, hat der Ausdruck links vom Zuweisungsoperator (L-Wert) eine andere Bedeutung als der rechts davon. Der Begri L-Wert wird hauptschlich in der Programmiersprache C benutzt, wo es vielfltige Mglichkeiten fr Berechnungen mit Adressen gibt. In Java ist die Verwendung von L-Werten (also Adressen) stark eingeschrnkt. Auer als Operanden einiger Operatoren (der Zuweisungs- sowie der ++ und ---Operatoren) kommen sie nirgends vor. Dort wo L-Werte verlangt werden, sind in Java nur Variablen und Array-Elemente (siehe Abschnitt 2.5) verwendbar, aber keine Literale und Rckgabewerte komplexerer Ausdrcke. Beispielsweise ist x+1 nicht als LWert verwendbar, da keine Speicheradresse bezeichnet wird. Zuweisungen wie x+1 = 2; und 1 = 2; sind nicht mglich weil sinnlos. Ebenso ist (x+1)++ keinesfalls erlaubt, da der Postx-Operator ++ einen L-Wert als Operanden verlangt. Steht ein Variablenname irgendwo anders als links vom Zuweisungsoperator (also nicht nur rechts vom Zuweisungsoperator, etwa als Operand von +), so wird auf den R-Wert der Variablen zugegrien. Wird eine Variable mit dem Modier final deklariert, darf ihr L-Wert nicht verwendet werden. Auf der linken Seite einer Zuweisung darf eine solche Variable genausowenig stehen wie als irgendein anderer Operand, der ein L-Wert sein muss. Beispielsweise ist x++ erlaubt, falls x nicht als final deklariert wurde, bei Deklaration als final jedoch verboten.
L-Werte und R-Werte.
Anweisungen sind Konstrukte, die Zustnde von Programmen dynamisch ndern oder den Programmuss steuern. Im Gegensatz zu Ausdrcken mssen Anweisungen keine Werte zurckgeben. Beispielsweise sind while-Schleifen Anweisungen, aber keine Ausdrcke.
Ausdrucksanweisungen.
97
2 Grundlegende Sprachkonzepte
Es gibt auch Ausdrucksanweisungen, also Ausdrcke, die zugleich als Anweisungen verwendbar sind. Das bedeutet, sie liefern Werte und ndern zustzlich den Programmzustand durch ndern von Variablenwerten. Man sagt, diese Ausdrcke haben Seiteneekte also neben dem Haupteekt des Zurckgebens von Werten noch zustzliche Eekte. Der Begri Seiteneekt3 macht klar, dass der zustzliche Eekt nur am Rande der Programmausfhrung passiert und seine Auswirkungen nicht immer leicht erkennbar sind. Das Ausnutzen von Seiteneekten in komplexeren Ausdrcken wird daher als schlechter Programmierstil angesehen. Programme knnten unbersichtlich werden. Trotzdem beruhen Programmausfhrungen in imperativen Sprachen zu einem groen Teil auf Seiteneekten. Ausdrucksanweisungen ermglichen eine kurze Schreibweise. Folgendes Beispiel zeigt eine Ausdrucksanweisung in der zweiten Zeile: int x = 5; int y = x++; Die erste Anweisung ist eine Variablendeklaration, wobei x der Wert 5 zugewiesen wird. In der zweiten Zeile wird eine weitere Variable y deklariert und ihr ebenfalls ein Wert zugewiesen, der sich folgendermaen ergibt: Die Ausdrucksanweisung x++ liefert den Wert von x, also 5 zurck und erhht danach als Seiteneekt den Wert der Variablen x um eins, sodass nach Durchfhrung dieser beiden Programmzeilen x den Wert 6 und y den Wert 5 hat. Ein weiteres Beispiel fr eine Ausdrucksanweisung ist die Zuweisung selbst siehe Abschnitt 2.2.2. Anwendungen von Operatoren auf komplexe Operanden verschachteln Ausdrcke zu noch komplexeren Ausdrcken. Bei der Auswertung verschachtelter Ausdrcke spielt die Auswertungsreihenfolge eine wichtige Rolle. Beispielsweise wird im Ausdruck
Auswertung von Ausdrcken.
3 - 4 * 2 zunchst der Teilausdruck mit dem Multiplikationsoperator 4*2 ausgewertet, bevor das Ergebnis der Subtraktion berechnet wird. Das Ergebnis ist also 5 und nicht 2. Wie fr die Mathematik in der Schule gilt: Punktrechnung geht vor Strichrechnung. Die zweistelligen Operatoren *
3
Bei uns hat sich der Begri Seiteneekt (gelegentlich auch Nebeneekt ) eingebrgert, obwohl die korrekte bersetzung des entsprechenden englischen Begris Side-Eect wohl eher Nebenerscheinung oder Nebenwirkung (bei Medikamenten) wre.
98
5 - 2 + 3
3 6 3
5 - 2 + 3
5 0 5
linksassoziativ
rechtsassoziativ
(Multiplikation) und / (Division) haben eine hhere Prioritt (Vorrangstufe) als die zweistelligen Operatoren + und -. Die Assoziativitt eines Operators bestimmt die Reihenfolge, in der Operatoren und Operanden verknpft werden, wenn ein Ausdruck aus mehreren Operatoren gleicher Prioritt zusammengesetzt ist. Man unterscheidet links- rechts- und nicht-assoziative Verknpfungen. Nichtassoziativ bedeutet einfach nur, dass eine Verknpfung nicht erlaubt ist, falls mehrere Operatoren die gleiche Prioritt haben. Im linksassoziativen Fall werden Ausdrcke von links nach rechts aufgelst, im rechtsassoziativen Fall von rechts nach links. Abbildung 2.9 zeigt eine links- und rechtsassoziative Verknpfung im Vergleich. Verknpfungen arithmetischer Operatoren sind in Java immer linksassoziativ, da dies eher der blichen Intuition entspricht. Bei der Auswertung von Ausdrcken ist auch die Auswertungsreihenfolge der Operanden zu beachten. In Java werden die Operanden generell von links nach rechts ausgewertet. Der Unterschied zur Assoziativitt wird an folgendem Beispiel klar: Der Ausdruck x++ - x wendet den zweistelligen Operator - auf die Operanden x++ und x an, ein und dieselbe Variable kommt also im linken und rechten Operanden vor. Links steht eine Ausdrucksanweisung, die den Wert von x liefert und den Seiteneekt hat, dass x um eins erhht wird. Der Wert des linken Operanden ist also der Wert von x vor der Erhhung. Bevor die Subtraktion durchgefhrt werden kann, mssen die beiden Operanden ausgewertet werden. Dabei wird immer zuerst der linke Operand ausgewertet, das heit, dessen Seiteneekte mssen bereits passiert sein, bevor der rechte Operand ausgewertet wird. Die Auswertung des rechten Operanden liefert also den Wert von x nach der Erhhung. Aufgrund der Auswertungsreihenfolge wird der gesamte Ausdruck immer zu 1 ausgewertet. Dagegen wird x-x++ immer zu 0 ausgewertet (beide Operanden vor Erhhung).
99
2 Grundlegende Sprachkonzepte
Obwohl Prioritt, Assoziativitt und Auswertungsreihenfolge in Java fr alle Operatoren ganz klar festgelegt sind, ist es fr Menschen oft schwierig, die Auswertung komplexerer Ausdrcke richtig nachzuvollziehen. Im Sinne einfacher Lesbarkeit sollten wir daher alle Ausdrcke vermeiden, die schwer nachvollziehbar sind. Eine einfache und empfehlenswerte Technik zur Verbesserung der Lesbarkeit ist die Verwendung von Klammern. Beispielsweise bietet der Ausdruck 3 - (4 * 2) keinen Interpretationsspielraum, auch wenn man die Operatorprioritten nicht kennt. Allerdings knnen Klammern die Lesbarkeit hinsichtlich der Auswertungsreihenfolge und damit von Seiteneekten nicht verbessern. In diesem Zusammenhang ist es hilfreich, statt einer Anweisung, die groe und komplexe Ausdrcke enthlt, mehrere Anweisungen mit einfacheren Ausdrcken zu verwenden und sparsam mit Seiteneekten umzugehen. 2.2.2 Operatoren in Java Im Folgenden geben wir einen berblick ber die gngigsten Operatoren in Java. Wir werden nicht auf alle Operatoren eingehen, sondern vor allem die einfachsten Operatoren berspringen. Detaillierte Beschreibungen aller Operatoren nden sie zum Beispiel in The Java Language Specication 4 und in zahlreichen Java-Bchern aus einer Buchhandlung oder Bibliothek. Dazu zhlen Addition +, Subtraktion -, Multiplikation *, Division / und der Restwertoperator %, der auch Modulusoperator genannt wird. Alle diese Operatoren sind Inx-Operatoren. Die Operanden knnen beliebige numerische Typen haben, sowohl ganze Zahlen als auch Fliekommazahlen. Beide Operanden mssen den gleichen numerischen Typ haben. Bei unterschiedlichen Typen wandelt der Compiler vor der Anwendung der Operation alle Operanden in den umfassenderen gemeinsamen Typ um. Wenn der Ausdruck zum Beispiel einen int- und einen double-Operanden hat, wird der int-Operand in einen double-Wert umgewandelt, bevor der Operator angewendet wird. Nach welchen allgemeinen Regeln diese Typumwandlungen erfolgen, wird in Abschnitt 2.2.3 besprochen.
Zweistellige arithmetische Operatoren.
4
Der Inhalt dieses Buches von den Java-Entwicklern kommt einer oziellen Denition der Sprache am nchsten siehe [Link]
100
Der Divisionsoperator / ist fr ganze Zahlen und Fliekommazahlen unterschiedlich deniert. Bei der Division von ganzen Zahlen (das heit, beide Operanden haben ganzzahlige Typen) wird zum Wert 0 hin ab- oder aufgerundet, sprich die Nachkommastellen werden verworfen, und das Ergebnis ist wieder eine ganze Zahl. Bei Operanden, die Fliekommazahlen sind, ist das Ergebnis wieder eine Fliekommazahl: 3.0 / 2 ergibt 1.5 (2 wird zur Fliekommazahl 2.0) 3 / 2.0 ergibt 1.5 (3 wird zur Fliekommazahl 3.0) 3 / 2 ergibt 1 (ganzzahlig, gegen 0 gerundet) Die Division durch 0 ist in der Mathematik undeniert. Es gibt keinen vernnftigen Wert, den man dem Ergebnis einer solchen Division geben knnte. Wenn wir in Java eine Ganzzahldivision mit dem Divisor 0 durchzufhren versuchen, wird zur Laufzeit eine ArithmeticException geworfen siehe Abschnitte 5.4.1 und 5.5 was ohne besondere Vorkehrungen zum Programmabbruch fhrt. Bei Fliekommazahlen liefert die Division durch 0.0 jedoch ein spezielles Ergebnis, nmlich Infinity (Unendlich) oder -Infinity (negativ Unendlich) siehe Abschnitt 6.2.2. Als Spezialfall liefert der Ausdruck 0.0/0.0 den Wert NaN (Not-A-Number ), der eigentlich keinen Wert reprsentiert. Die speziellen Werte Infinity, -Infinity und NaN sind zwar Instanzen von double und float, aber keine Zahlen im engeren Sinn. Tritt einer dieser Werte als Operand in einem Ausdruck auf, so ist auch das Ergebnis einer dieser Werte. Man kann mit diesen speziellen Werten also nicht mehr normal weiterrechnen. Der Restwertoperator % liefert den Rest der Ganzzahldivision. Beispielsweise liefert der Ausdruck 5%2 den Wert 1, weil bei der Ganzzahldivision 5/2 als Rest 1 brig bleibt. Der Rest wird in Java negativ wenn der Dividend negativ ist. Daher ergibt (-5)%2 den Wert -1. In der Praxis wird der Restwertoperator fast ausschlielich auf ganzen Zahlen eingesetzt, wie wir bereits an einem Beispiel in Abschnitt 1.1 gesehen haben. Dennoch ist der Operator auch auf Fliekommazahlen deniert. Wenn einer der Operanden eine Fliekommazahl ist, wird als Ergebnis wieder eine Fliekommazahl geliefert. Ein Beispiel: (-5) % 2.25 ergibt -0.5 weil 5,0 = 2 2,25 + 0,5 gilt und der Dividend negativ ist. Der Wert 2 ergibt sich daraus, dass 2,25 in 5,0 zwei mal vollstndig enthalten ist und 0,5 bleibt brig, wenn man 2,25 zweimal von 5,0 abzieht.
101
2 Grundlegende Sprachkonzepte
Hinsichtlich der Division durch 0 hnelt der Restwertoperator dem Divisionsoperator: Auf ganzen Zahlen wird eine ArithmeticException geworfen, und auf Fliekommazahlen ist das Ergebnis NaN. Das Operatorsymbol + wird nicht nur zur Addition, sondern ebenso als zweistelliger Inx-Operator zur Verkettung von Zeichenketten benutzt. Ein Beispiel:
Verkettungs-Operator.
String s1 = "Hallo "; String s2 = s1 + "Karl"; [Link](s2); //gibt "Hallo Karl" aus Man sagt, der Inx-Operator + ist berladen. In einem Ausdruck x+y wird + als Additions-Operator aufgefasst, wenn sowohl x als auch y Zahlen sind, also einen numerischen Typ haben, Verkettungs-Operator aufgefasst, wenn x oder y vom Typ String ist (oder beide vom Typ String sind). In allen anderen Fllen liefert der Compiler eine Fehlermeldung. Wenn nur ein Operand vom Typ String und der andere von irgendeinem anderen Typ ist, dann wird der andere Operand in eine Zeichenkette umgewandelt, bevor die beiden Zeichenketten verkettet werden. Beispielsweise gibt [Link]("3 + 4 = " + (3 + 4)); die Zeichenkette "3 + 4 = 7" aus: Der linke Operand von + auerhalb der Klammern ist eine Zeichenkette (die zufllig das Zeichen + enthlt). Daher ist dieser Operator der Verkettungs-Operator. Im rechten Operanden wird der Additions-Operator auf 3 und 4 angewandt, und das Ergebnis 7 wird in die Zeichenkette "7" umgewandelt. Versuchen Sie als bungsaufgabe zu bestimmen, welche Ausgabe folgende Anweisung (ohne Klammerung des arithmetischen Ausdrucks) erzeugen wrde: [Link]("3 + 4 = " + 3 + 4); Zu den unren arithmetischen Operatoren zhlen der positive + und negative - Vorzeichenoperator. Der positive Vorzeichenoperator wird selten verwendet, da er einfach den Wert
Einstellige arithmetische Operatoren.
102
seines Operanden liefert. So liefert +x einfach den Wert von x. Der negative Vorzeichenoperator kehrt das Vorzeichen seines Operanden um. So liefert beispielsweise -x den negierten Wert von x oder - -5 den Wert 5. Das Leerzeichen zwischen den beiden Vorzeichenoperatoren ist hier wichtig, um sie vom Prx-Dekrementoperator -- zu unterscheiden; --5 wre kein gltiger Ausdruck weil 5 kein L-Wert ist. Der Prx- bzw. Postx-Inkrementoperator ++ und der Prx- bzw. Postx-Dekrementoperator -- gehren ebenfalls zu den unren arithmetischen Operatoren. Im Gegensatz zu allen anderen arithmetischen Operatoren haben diese Operatoren einen Seiteneekt: Sie liefern nicht nur einen Wert zurck, sondern verndern den Operanden auch. Daher mssen Operanden dieser Operatoren L-Werte sein. Die Inkrementoperatoren erhhen den Wert um eins, die Dekrementoperatoren verringern ihn um eins. Als Prxoperatoren ndern sie zuerst den Wert und liefern dann den vernderten Wert zurck. Wenn die Variable x beispielsweise den Wert 5 enthlt, liefert --x als Ergebnis 4 und ndert den Wert von x auf 4: int x = 5; int y = --x; // x hat den Wert 4, y hat den Wert 4 Analog liefert, wenn x den Wert 5 enthlt, ++x als Ergebnis 6 und ndert den Wert von x auf 6. Als Postxoperatoren liefern ++ und -zuerst den unvernderten Wert zurck und ndern ihn erst dann: int x = 5; int y = x--; // x hat den Wert 4, y hat den Wert 5 Zuweisungs-Operatoren sind binre Operatoren mit Seiteneekten. Der linke Operand muss bei allen Zuweisungoperatoren ein L-Wert sein, der rechte Operand jedoch nicht. Zu den Zuweisungsoperatoren gehren der einfache Zuweisungsoperator = sowie die kombinierten Zuweisungsoperatoren, deren Symbol sich aus einem entsprechenden binren arithmetischen oder Bit-Operatorsymbol mit nachfolgendem = zusammensetzt. Beispielsweise ist += der Additions-Zuweisungsoperator. Der einfache Zuweisungsoperator liefert als Rckgabewert den Wert des rechten Operanden. Da eine Zuweisung auch ein Ausdruck ist, ist beispielsweise folgende Schreibweise mglich:
Zuweisungsoperatoren.
int x, y, z; x = y = z = 1;
103
2 Grundlegende Sprachkonzepte
Zuerst wird der Variablen z der Wert 1 zugewiesen. Die Zuweisung z=1 liefert als Ausdruck den zugewiesenen Wert. Damit erhlt y ebenfalls den Wert 1. Zuletzt wird = ganz links ausgewertet, und damit bekommt auch x den Wert 1. Der Ausdruck wird von rechts nach links wie in x=(y=(z=1)) abgearbeitet. Das heit, = ist rechtsassoziativ. Zuweisungsoperatoren haben eine sehr niedrige Prioritt. Daher ist beispielsweise die Schreibweise y = x + 1; mglich, ohne dass der arithmetische Ausdruck auf der rechten Seite in Klammern gesetzt werden muss. Zuerst wird der arithmetische Operator angewendet und danach das Ergebnis zugewiesen. Der Additions-Zuweisungsoperator += kombiniert eine Zuweisung mit einer Addition: Der Wert des rechten Operanden wird zum linken Operanden addiert. Beispiele: x += 1; gleichbedeutend mit x = x + 1; x += y = 1; gleichbedeutend mit x = x + (y = 1); Das letzte Beispiel zeigt, dass der rechte Operand immer zuerst ausgewertet wird, trotz hherer Prioritt der Addition. Die Semantik der kombinierten Zuweisungsoperatoren ist also allgemein: L op= R; gleichbedeutend mit L = L op (R); wobei op arithmetischer Operator oder Bit-Operator und L bzw. R der linke bzw. rechte Operand ist. Abbildung 2.10 zeigt eine Liste aller kombinierten Zuweisungsoperatoren in Beispielen. Die letzten 6 Zeilen in dieser Liste stellen Bitoperatoren dar, die weiter unten besprochen werden. Das sind binre Inx-Operatoren, die als Operanden numerische Ausdrcke haben und einen Wahrheitswert, also einen Wert vom Typ boolean zurckliefern. Die relationalen Operatoren werden daher manchmal auch als Boolesche Operatoren bezeichnet. Es gibt den Kleineroperator <, Greroperator >, den Kleinergleichoperator <= und den Grergleichoperator >=. Weiters gibt es Gleichheitsoperatoren fr den Test auf Gleichheit == und Ungleichheit !=. Letztere haben eine niedrigere Prioritt als die anderen relationalen Operatoren.
Relationale Operatoren.
104
2.2 Ausdrcke und Operatoren x x x x x x x x x x x += 2 -= 3 *= 4 /= 5 %= 6 &= 255 |= 8 = y <<= 1 >>= 1 >>>= 3 // // // // // // // // // // // x x x x x x x x x x x = = = = = = = = = = = x x x x x x x x x x x + 2 - 3 * 4 / 5 % 6 & 255 | 8 y << 1 >> 1 >>> 3
2 == 2 2 == 3 3 < 2 1 < 2 3 < 5 3 > 3 3 >= 3 3 <= 3 2 <= 3 2 != 3 2 != 2 true == false true == 2 <= 3
// // // // // // // // // // // // //
true false false true true false true true true true false false true
Die Gleichheitsoperatoren knnen nicht nur auf numerische Operanden angewendet werden, sondern auch auf Boolesche Werte und auf Referenzen. Dieses Thema wird spter aufgegrien. Abbildung 2.11 zeigt einige Beispiele fr die Anwendung relationaler Operatoren.
105
2 Grundlegende Sprachkonzepte Ausdruck false && false && true && true && Ausdruck false false true true Wert false false false true Wert false true true false Ausdruck false || false || true || true || Ausdruck !false !true Wert false true true true Wert true false
Logische Operatoren verknpfen Boolesche Werte (vom Typ boolean) zu einem Booleschen Ergebniswert. Es gibt zweistellige Inx-Operatoren fr das logische UND &&, das logische ODER || und das logische Exklusiv-ODER sowie einen einstelligen PrxOperator, den Negationsoperator !. Die Funktionalitt der Operatoren ist anhand der Verknpfungen in Abbildung 2.12 vollstndig deniert. Unter den logischen Operatoren hat ! die hchste Prioritt, gefolgt von . Danach kommt && mit niedrigerer Prioritt und schlielich || mit der niedrigsten Prioritt. Die Prioritt aller logischer Operatoren mit Ausnahme der Negation ! ist niedriger als die der Booleschen Operatoren. Der Ausdruck x < 5 && y == 0 || y == x ist daher gleichbedeutend mit ((x < 5) && (y == 0)) || (y == x). Bei der Verwendung der Operatoren && und || wird der rechte Operator nicht immer ausgewertet. Er wird nur dann ausgewertet, wenn er das Ergebnis noch beeinussen kann. Ist bei && der linke Operand falsch, kann die Verknpfung nicht mehr wahr werden, und es wird gleich false zurckgegeben, ohne den rechten Operanden auszuwerten. Ist bei || der linke Operand wahr, kann der Ausdruck nicht mehr falsch werden, und es wird gleich true zurckgegeben. Auf diese Weise kann der Compiler bzw. die Laufzeitumgebung den Programmuss abkrzen. Man nennt diese Operatoren daher auch Kurzschlussoperatoren. Diese Optimierungen mssen speziell dann beachtet werden, wenn die Operanden Ausdrucksanweisungen mit Seiteneekten sind.
Logische Operatoren.
106
Sollen bei UND- und ODER-Operationen in Ausnahmefllen beide Operanden ausgewertet werden, kann man statt der Kurzschlussoperatoren die Operatoren & und | verwenden. Normalerweise fhrt man mit diesen Operatoren jedoch Bit-Operationen aus. Diese fhren Manipulationen auf dem Bitmuster eines Wertes durch. Es gibt in Java vier logische Bit-Operatoren und drei ShiftOperatoren. Die logischen Bit-Operatoren fhren eine bitweise logische Operation durch. Dabei entspricht ein Bit im Zustand 1 einem true und im Zustand 0 einem false. In den folgenden Beispielen verwenden wir als Operanden die intVariablen m und n aus Abbildung 2.3. Die entsprechenden Darstellungen der Werte als Bitmuster im Binrzahlensystem werden rechts als Kommentar angegeben. Ein Beispiel fr den bitweisen UND -Operator &:
Bit-Operatoren.
int m = 1027; // 00000000000000000000010000000011 int n = 395; // 00000000000000000000000110001011 int r = m & n; // 00000000000000000000000000000011 // r hat den Wert 3 In diesem Beispiel wird r als Ergebnis der bitweisen UND-Verknpfung von m und n der Wert 3 zugewiesen. Der Wert ergibt sich, weil die beiden niedrigstwertigen Bits die einzigen sind, die im Bitmuster von m und n auf 1 gesetzt sind. Das int-Bitmuster, in dem diese beiden Bits als einzige auf 1 gesetzt sind, entspricht dem Wert 3. Ein Beispiel fr den bitweisen ODER-Operator |: int m = 1027; // 00000000000000000000010000000011 int n = 395; // 00000000000000000000000110001011 int r = m | n; // 00000000000000000000010110001011 // r hat den Wert 1419 In diesem Beispiel wird r als Ergebnis der bitweisen ODER-Verknpfung von m und n der Wert 1419 zugewiesen. Das Bitmuster von 1419 entsteht, wenn man nur all jene Bits auf 1 setzt, wo mindestens eine der beiden Variablen an der entsprechenden Position im Bitmuster ein Bit 1 hat. Der bitweise Exklusiv-ODER-Operator funktioniert analog und wird hier nicht weiter besprochen. Die zweistelligen logischen Bit-Operatoren lassen sich auch auf boolean-Operanden anwenden. Sie liefern dasselbe Ergebnis wie die Kurzschlussoperatoren &&, || und . Der Unterschied besteht nur darin, dass in jedem Fall beide Operanden ausgewertet werden.
107
2 Grundlegende Sprachkonzepte
Der bitweise Negationsoperator ~ ist ein einstelliger Prx-operator: int m = 1027; int r = ~m; // 00000000000000000000010000000011 // 11111111111111111111101111111100 // r hat den Wert -1028
Jedes Bit wird invertiert, das heit, aus 0 wird eine 1 und aus 1 wird 0. In der fr ganze Zahlen verwendeten Zweierkomplementdarstellung entsprechen alle Bitmuster mit dem hchstwertigen Bit 1 einer negativen Zahl. Daher ndert ~ immer das Vorzeichen. Die drei Shift-Operatoren sind zweistellige Inx-Operatoren, die nur ganzzahlige Operanden haben knnen. Sie verschieben das Bitmuster der Zahl im linken Operanden um die Anzahl der Stellen im rechten Operanden. Mit dem Operator << werden die Bits nach links verschoben, mit >> unter Beachtung des Vorzeichens nach rechts, und mit >>> ohne Beachtung des Vorzeichens ebenfalls nach rechts. Beispiele: int int r = r = r = m = 1027; // r = m >> 3; // m << 3; // ~m >> 3; // ~m >>> 3; // 00000000000000000000010000000011 00000000000000000000000010000000 00000000000000000010000000011000 11111111111111111111111101111111 00011111111111111111111101111111
Der linke Operand bestimmt ein Bitmuster, und der rechte Operand gibt an, um wieviele Stellen das Bitmuster zu verschieben ist. Beim Verschieben geht links oder rechts eine entsprechende Anzahl von Bits verloren, die jeweils von der anderen Seite durch die gleiche Anzahl an Bits aufgefllt werden. Bei >>> und << wird immer mit 0 aufgefllt. Bei >> wird mit dem Wert des hchstwertigen Bits aufgefllt. Das Verschieben um n Bits nach links entspricht der Multiplikation mit n 2 , das Verschieben nach rechts mittels >> der Division durch 2n . Daher sind diese Operatoren auch fr arithmetische Berechnungen verwendbar. In Java gibt es nur einen dreistelligen Operator, den Bedingungoperator. Er kann eine if-else-Anweisung (Abschnitt 2.3.2) durch einen Ausdruck ersetzen:
Bedingungsoperator.
boolescherAusdruck ? ausdruck1 : ausdruck2 liefert als Ergebnis den Wert von ausdruck1 wenn boolescherAusdruck true liefert und sonst den Wert von ausdruck2. Es werden also immer
108
zwei der drei Operanden ausgewertet. Die Operanden ausdruck1 und ausdruck2 mssen zueinander zuweisungskompatibel sein. Beispielsweise lsst sich das Maximum von zwei Variablen m und n entweder so berechnen: if (m > n) { max = m; } else { max = n; } oder mit Hilfe des Bedingungsoperators so: max = m > n ? m : n; Aufgrund der Prioritt der in diesem Beispiel angewendeten Operatoren ist keine Klammerung notwendig siehe Tabelle 2.13. 2.2.3 Typumwandlungen und Literale Die Typisierung sollte nicht allzu einschrnkend wirken. So erlauben Typumwandlungen in Java das Verknpfen von Operanden unterschiedlichen Typs. Zum Beispiel werden die folgenden Zeilen vom Compiler akzeptiert, obwohl darin Werte mehrerer elementarer Typen gemischt vorkommen: int m = 14; long n = m; long r = 10; In der zweiten Zeile wird implizit der int-Wert von m in einen long-Wert umgewandelt, bevor der Wert der Variablen n zugewiesen wird. Somit ist der zugewiesene Wert vom Typ long. Genauso wird in der dritten Zeile das int-Literal 10 implizit in eine Zahl vom Typ long umgewandelt. Das entsprechende long-Literal wre 10L. Solche Typumwandlungen laufen nach bestimmten Regeln ab. Man unterscheidet zwei Arten von Typumwandlungen: Erweiternde Typumwandlungen: Diese erfolgen von einem kleineren hin zu einem greren Datentyp. Der Wert des kleineren Datentyps ist als Wert des greren Datentyps immer darstellbar. Allerdings kann es zu kleinen Fehlern aufgrund der Darstellung der Werte kommen, z.B. zu Rundungsfehlern bei der Umwandlung von int nach float,
109
2 Grundlegende Sprachkonzepte
Operatoren [] () . ++, -++, -+, ~ ! (Typ) new *, /, % +, + << >> >>> <, <=, >, >= instanceof ==, != & | && || ?: = *=, /=, %=, +=, -=, <<=, >>=, >>>=, &=, =, |= Prioritt 1 1 1 1 2 2 2 2 3 3 4 5 5 6 6 6 7 7 8 9 10 11 12 13 14 15 16 Assoziativitt links links links links rechts rechts rechts rechts rechts rechts links links links links links links links links links links links links links links rechts rechts rechts Bedeutung Arrayzugri Methodenaufruf Komponentenzugri Postinkrement, Postdekrement Prinkrement, Prdekrement unres Plus und Minus bitweises Komplement logisches Komplement Cast Erzeugung (siehe Kapitel 3) Multiplikation, Division, Rest Addition und Subtraktion Stringverkettung Linksshift Rechtsshift mit Vorzeichenerweiterung Rechtsshift ohne Vorzeichenerw. numerische Vergleiche Typvergleich (siehe Kapitel 3) Gleich-/Ungleichheit bitweises/logisches UND bitweises/logisches exklusives ODER bitweises/logisches ODER logisches konditionales UND logisches konditionales ODER Bedingungsoperator Zuweisung kombinierter Zuweisungoperator
da die Fliekommazahlen nicht beliebig dicht aufeinander folgen. Die erweiternden Typumwandlungen werden vom Compiler automatisch durchgefhrt siehe obige Beispiele. Einschrnkende Typumwandlungen: Diese erfolgen von einem greren hin zu einem kleineren Datentyp. Dabei kann es zu einem Verlust kommen, da der Wert des greren Typs im Allgemeinen nicht als Wert des kleineren Typs darstellbar ist. Einschrnkende Typumwandlungen werden nicht automatisch durchgefhrt. Man muss dem
110
short
char
int
long
float
double
Compiler explizit mitteilen, dass eine solche Umwandlung erwnscht ist und man einen etwaigen Verlust in Kauf nimmt. Die explizite Typumwandlung erfolgt mit dem Cast -Operator. Eine explizite Typumwandlung hat folgende Form: float x = 13.5f; int y = (int) x; //Rundung gegen 0: y == 13 In Tabelle 2.14 sind Typumwandlungen fr elementare Datentypen dargestellt. Man beachte, dass abweichend von der allgemeinen Regel von byte und short zu char nur explizite Typumwandlungen erfolgen knnen. Ausdrcke vom Typ boolean knnen weder implizit noch explizit umgewandelt werden und kommen daher in der Tabelle nicht vor. Der Cast-Operator ist auch fr Referenztypen sinnvoll, hat dort aber eine andere Bedeutung, wie wir in Abschnitt 3.4.3 sehen werden. Verknpft man in einem Ausdruck Operanden der Typen byte, short oder char (also Typen mit weniger Bytes als int), so werden diese meist vor der Verknpfung implizit in den Datentyp int umgewandelt. Diese implizite Umwandlung wird Integer-Erweiterung (IntegralPromotion ) genannt. Sie hat zur Folge, dass in manchen Fllen eine explizite Umwandlung erforderlich ist:
Erweiterung.
short x = 30; short y = +x; // Compilerfehler: +x liefert int short y = (short)+x; // so geht es
111
2 Grundlegende Sprachkonzepte
Bei zweistelligen Operatoren werden die beiden Operanden in den grten gemeinsamen Typ umgewandelt. Dieser gemeinsame Typ bestimmt auch den Typ des Ergebnisses. Ein Beispiel: 1.0 + 5 * 3L; Hier wird aufgrund seiner hheren Prioritt zunchst der Multiplikationsoperator * angewendet. Dabei wird das int-Literal auf den Typ long gebracht und die Multiplikation liefert einen long-Wert. Dieser wird vor der Berechnung der Summe wegen des linken Operandentyps in einen double-Wert umgewandelt. Der Typ des ganzen Ausdrucks ist double. Ein Ausdruck ist konstant, wenn bereits vom Compiler der Rckgabewert des Ausdrucks berechnet wird. Das ist nur mglich, wenn der Ausdruck keine Variablen enthlt, denn Werte von Variablen, die erst zur Laufzeit initialisiert werden und sich whrend der Programmausfhrung ndern knnen, kennt der Compiler ja nicht. Im Wesentlichen enthalten konstante Ausdrcke nur Literale, die mglicherweise ber Operatoren miteinander verknpft sind. Steht in einer Zuweisung ein konstanter Ausdruck des Typs int, so kann dessen Wert auch einer Variablen eines kleineren Typs ohne explizite Typumwandlung zugewiesen werden, wenn der Wert ohne Informationsverlust in den Typ passt. Das wird statisch berprft. Beispiel:
Konstante Ausdrcke.
Diese Regel bewirkt, dass wir keine eigenen Literale fr byte- und shortWerte brauchen. Wir knnen dafr einfache int-Literale verwenden. Die statische berprfung, ob konstante int-Werte ohne Informationsverlust in einen kleineren Typ passen, darf uns nicht zur Annahme verleiten, dass der Compiler generell auf mgliche Informationsverluste hinweist. Das ist leider nicht der Fall, wie man hier sieht: long n = 2147483647 * 2L; // n == 4294967294L long n = 2147483647 * 2; // n == -2L !Fehler! Die Zahl 2147483647 ist gerade noch als int-Wert darstellbar, der doppelte Wert jedoch nicht mehr; der ist nur als long-Wert darstellbar. In der ersten dieser beiden Zeilen wird die Zahl vor der Multiplikation in einen
112
long-Wert umgewandelt, weil der zweite Operand vom Typ long ist. Daher ist auch das Ergebnis vor der Zuweisung vom Typ long. In der zweiten Zeile werden dagegen zwei int-Werte miteinander multipliziert, und das Ergebnis ist der int-Wert -2, der zufllig durch Abschneiden nicht mehr darstellbarer Bits entsteht. Dieser falsche int-Wert wird schlielich zu long konvertiert und an n zugewiesen. Weder vom Compiler noch zur Laufzeit gibt es eine Fehlermeldung. Aus diesem Grund mssen wir beim Programmieren besonders darauf achten, dass alle unsere Werte in den Typen, die wir verwenden, darstellbar sind.
Literale.
Fehler wie dieser entstehen leicht aus Unachtsamkeit, weil wir Literale der falschen Typen verwenden. Daher ist es wichtig, die Literale zu kennen und auf deren Typen zu achten. Fr boolean gibt es nur die beiden Literale false und true. Fr ganze Zahlen gibt es mehrere Arten von Literalen. Einerseits unterscheiden wir zwischen Literalen vom Typ int und solchen vom Typ long. Letztere sind gleich aufgebaut wie int-Literale, enden aber mit L oder l; Klein- und Groschreibung spielt hier keine Rolle. Eigene Literale fr byte und short gibt es nicht. Sowohl int- als auch long-Literale knnen in drei unterschiedlichen Zahlensystemen deniert werden: Meist verwenden wir Literale im Dezimalsystem, also Zahlen auf der Basis von zehn. Alle (nicht durch andere Zeichen unterbrochenen) Ziernfolgen, die nicht mit 0 beginnen, stellen Dezimalzahlen dar. Eine Ziernfolge, die mit 0 beginnt, wird als Oktalzahl gesehen, also als Zahl auf der Basis von acht. In der Ziernfolge drfen nur die Ziern 0 bis 7 vorkommen. Beispielsweise entspricht die Oktalzahl 024 der Dezimalzahl 20, da 2 81 + 4 80 = 2 101 + 0 100 gilt. Aufgrund dieser Zahlendarstellung drfen wir niemals fhrende Nullen vor Dezimalzahlen schreiben. Hexadezimalzahlen, das sind Zahlen auf der Basis von 16, beginnen mit 0x oder 0X gefolgt von einer Folge von Ziern und den Buchstaben a bis f oder A bis F, wobei a und A fr zehn, b und B fr elf, und so weiter bis f und F fr 15 stehen. Beispielsweise entspricht die Hexadezimalzahl 0x2B der Dezimalzahl 43, da 2 161 + 11 160 = 4 101 + 3 100 gilt. Hug stellt man damit Binrzahlen dar, da jedes Byte (8 Bit) durch nur zwei Hexadezimalziern (2 mal 4 Bit) darstellbar ist.
113
2 Grundlegende Sprachkonzepte \b \f \t \ \" \\ \r \n \ooo \uxxxx Backspace (Rckschritt) Formfeed (Seitenumbruch) horizontaler Tabulator einfaches Anfhrungszeichen doppeltes Anfhrungszeichen Backslash Cursor-Return (Wagenrcklauf) Newline (Zeilenschaltung) ASCII-Code des Zeichens, 3-stellige Oktalzahl ooo Unicode des Zeichens, 4-stellige Hexadezimalzahl xxxx
Auch fr Fliekommazahlen gibt es mehrere Formen von Literalen. Literale vom Typ float unterscheiden sich von denen vom Typ double durch ein hinten angehngtes f oder F. An ein Literal vom Typ double knnen wir hinten ein d oder D anhngen, mssen aber nicht. Weiters knnen wir zwei Formen von Fliekomma-Literalen unterscheiden: Die normale Darstellung beginnt mit einer ganzen Dezimalzahl gefolgt von . und einer weiteren Dezimalzahl (mglicherweise mit fhrenden Nullen). Ein Beispiel ist 12.34. Abgesehen davon, dass wir, wie im englischsprachigen Raum blich, einen Punkt statt dem im deutschsprachigen Raum blichen Komma verwenden, entspricht diese Darstellung unserer Intuition. In der wissenschaftlichen Darstellung beginnt die Zahl wie in der normalen Darstellung, und danach folgt ein e oder E und der Exponent als positive oder negative ganze Dezimalzahl. Beispielsweise steht 12.34e3 fr 12,34 103 und entspricht damit der Fliekommazahl 12340.0, und 12.34E-3 steht fr 12,34 103 und entspricht damit 0.01234. Zeichen-Literale sind in einfache Anfhrungszeichen gesetzte Zeichen wie z.B. x, & und 3. Es gibt jedoch auch Zeichen, die nicht direkt ber die Tastatur eingegeben und am Bildschirm dargestellt werden knnen. Fr diese Zeichen gibt es spezielle Darstellungsformen, sogenannte Escape-Sequenzen, die in Tabelle 2.15 aufgelistet sind. Beispielsweise
114
ist \n das Zeichen-Literal fr die Zeilenumschaltung. Das Zeichen \ spielt die Rolle eines Escape-Zeichens, das heit, spezielle Zeichen werden mit \ eingeleitet. Um Verwechslungen zu vermeiden ist das ZeichenLiteral fr \ daher \\ und nicht \. Jedes Zeichen entspricht auch einer Zahl. Zeichen, die Zahlen zwischen 0 und 255 (oder zwischen 128 und 127 wenn man das erste Bit als Vorzeichen interpretiert) entsprechen, knnen durch \ooo dargestellt werden, wobei ooo eine dreistellige Oktalzahl ist. Alle Zeichen knnen auch durch \uxxxx dargestellt werden, wobei xxxx eine vierstellige Hexadezimalzahl ist. Beispielsweise reprsentieren folgende Literale dasselbe Zeichen: \101, \u0041 und A. Der einzige Referenztyp, fr den es Literale gibt, ist String. Literale von Zeichenketten werden innerhalb doppelter Anfhrungszeichen angeschrieben, die in der gleichen Zeile enden, in der sie beginnen. Man kann dieselben Escape-Sequenzen wie in Zeichen-Literalen verwenden. Beispielsweise ist "Hello!\nBye!" eine Zeichenkette, die sich bei der Ausgabe ber zwei Zeilen erstreckt.
115
2 Grundlegende Sprachkonzepte
Syntaktisch wird ein Block durch geschwungene Klammern { und } begrenzt. Er umfasst meist mehrere Anweisungen, blicherweise jede in einer eigenen Zeile, und wird in folgender Form angeschrieben: { Anweisung1 Anweisung2 . . . Anweisungk } Da Blcke selbst Anweisungen sind, kann in einem Block ein weiterer Block enthalten sein. Es entstehen verschachtelte Unterblcke. In einem Anweisungsblock deklarierte Variablen nennt man lokale Variablen. Sie knnen an jeder Stelle im Block deklariert werden. Auf sie kann man in allen auf die Deklaration folgenden Anweisungen innerhalb des Blocks zugreifen. Die Variable hrt auf zu existieren, sobald der Kontrolluss das Ende des Blocks erreicht. In Abschnitt 2.1.2 wurden Gltigkeitsbereich und Lebensdauer einer Variablen eingefhrt. Der Gltigkeitsbereich ist jener Teil des Programms, in dem sich der Name einer Variablen auf diese Variable bezieht fr eine lokale Variable der Abschnitt zwischen Deklaration und Blockende. Die Lebensdauer umfasst den entsprechenden Zeitabschnitt whrend der Programmausfhrung. In diesem Zeitabschnitt ist fr die Variable ein Speicherplatz reserviert. Sowohl Gltigkeitsbereich als auch Lebensdauer lokaler Variablen sind daher eingeschrnkt. Nach ihrer Deklaration muss einer lokalen Variable zunchst ein Wert zugewiesen werden, bevor sie gelesen werden kann. Vergisst man darauf,
116
Listing 2.17: Name-Clash mehrere Variablen gleichen Namens { int m = 1; int n = 2; { int m; //Fehler m = n; } }
Listing 2.18: Zugri auf eine Variable auerhalb ihres Gltigkeitsbereichs { int m; int n; m = 1; n = 2; { int o; o = m; } [Link](o); // Fehler }
wird der Compiler beim bersetzen des Programms eine Fehlermeldung ausgeben und keinen ausfhrbaren Code erzeugen. Listing 2.16 bis 2.18 geben Beispiele fr fehlerhafte Blcke. Programmteile, welche solche Blcke enthalten, wrden sich nicht bersetzen lassen. In Listing 2.16 wird fr die Auswertung eines Ausdrucks der Wert der Variablen m bentigt, aber m davor nicht initialisiert. Daher gibt es diesen Wert nicht. Listing 2.17 zeigt, dass alle gltigen Namen lokaler Variablen in einem Block verschieden sein mssen. In Java gilt das auch dann, wenn eine Variable in einem inneren Block deklariert wird. Listing 2.18 zeigt zwei verschachtelte Blcke. Die Variable m ist auch im inneren Block gltig, daher ist die Zuweisung von m an o erlaubt. Jedoch ist o nur im inneren Block gltig und im ueren Block nicht zugreifbar.
117
2 Grundlegende Sprachkonzepte
Listing 2.19: Beispiel fr eine if-Anweisung mit else-Zweig if (wochenstunden > 40) { gehalt = stundenlohn*40 + 1.5*stundenlohn*(wochenstunden - 40); } else { gehalt = stundenlohn*wochenstunden; }
Listing 2.20: Beispiel fr eine if-Anweisung ohne else-Zweig gehalt = wochenstunden * stundenlohn; if (wochenstunden > 40) gehalt += 0.5*stundenlohn*(wochenstunden - 40);
2.3.2 Selektion mit if-else Die wichtigste Form einer bedingten Anweisung ist die if-Anweisung, die aufgrund einer Bedingung entscheidet, ob die darauolgende Anweisung ausgefhrt wird oder nicht. Die if-Anweisung hat folgende Syntax: if ( boolescherAusdruck ) Anweisung1 else Anweisung2 if ( boolescherAusdruck ) Anweisung1
oder
Liefert die Auswertung von boolescherAusdruck den Wert true, dann wird nur Anweisung1 ausgefhrt. Sonst wird nur Anweisung2 ausgefhrt. Wird der else-Zweig weggelassen, dann hat fr den Fall, dass boolescherAusdruck false liefert, die if-Anweisung keine weitere Auswirkung. Listing 2.19 und 2.20 zeigen zwei semantisch quivalente Programmfragmente als Beispiel fr den Einsatz von if-Anweisungen, einmal mit und einmal ohne else-Zweig. Beide Programme berechnen das Gehalt fr die in einer Woche geleistete Arbeit unter Bercksichtigung von berstunden, d.h. Arbeitsstunden, die ber 40 Stunden hinausgehen. Fr berstunden wird der eineinhalbfache Stundenlohn berechnet. Wie in Listing 2.19 gezeigt schreibt man aus pragmatischen Grnden meist Klammern um die einzelnen Programmzweige, damit sptere Erweiterungen vereinfacht wer-
118
Listing 2.21: Beispiel fr verschachtelte if-Anweisungen if (a > b) { if (a > c) { max = a; } else { max = c; } } else { if (b > c) { max = b; } else { max = c; } }
Listing 2.22: Beispiel fr eine Mehrfach-Selektion mit if-Anweisungen //0.0 <= punkte und punkte <= 100.0 if (punkte > 87.5) { note = "sehr gut"; } else if (punkte > 75.0) { note = "gut"; } else if (punkte > 62.5) { note = "befriedigend"; } else if (punkte > 50) { note = "gengend"; } else { note = "nicht gengend"; }
den. Wie in Listing 2.20 funktionieren die Programme aber auch ohne Klammern, solange ein Zweig aus genau einer Anweisung besteht. Jeder Zweig einer if-Anweisung kann wieder eine if-Anweisung sein. Dadurch wird eine Mehrfach-Selektion ermglicht. Ein Beispiel ist in Listing 2.21 zu sehen. Diese Verschachtelung von if-Anweisungen berechnet das Maximum von 3 Werten, die in den Variablen a, b und c gespeichert sind. Nach der Durchfhrung dieser Anweisung enthlt die Variable max das Maximum von a, b und c. Versuchen Sie die Anweisung fr 3 beliebige Werte von a, b und c nachzuvollziehen.
119
2 Grundlegende Sprachkonzepte
Mit der if-Anweisung knnen alle Arten von Verzweigungen bewerkstelligt werden. Auch Mehrfach-Selektion ist durch Verschachtelung mglich siehe Listing 2.22. 2.3.3 Mehrfach-Selektion mit der switch-Anweisung Fr eine Mehrfach-Selektion, bei der als Bedingungen nur Vergleiche mit konstanten Ausdrcken auftreten, ist eine switch-Anweisung verwendbar. Sie ist um eine Spur einfacher und bersichtlicher als verschachtelte if-Anweisungen und hat folgende Form: switch ( Ausdruck ) { case konstanterAusdruck 1: Anweisungsfolge 1 break; case konstanterAusdruck 2: Anweisungsfolge 2 break; . . . case konstanterAusdruck k : Anweisungsfolge k break; default: defaultAnweisungsfolge } Der Rumpf der switch-Anweisung wird auch switch-Block genannt. Er enthlt Anweisungen mit vorangestellten case-Sprungmarken. Diese Sprungmarken enthalten konstante Ausdrcke, die dem Typ des switchAusdrucks (steht in der ersten Zeile) zuweisbar sein mssen. Ist der Wert des switch-Ausdrucks gleich einem Wert eines konstanten Ausdrucks, wird die Ausfhrung des Programms bei der ersten Anweisung nach der entsprechenden Sprungmarke weitergefhrt. Wird kein passender konstanter Ausdruck gefunden, wird die Ausfhrung bei der ersten Anweisung nach der default-Sprungmarke (falls vorhanden) fortgesetzt. Die in der switch-Anweisung verwendeten Ausdrcke drfen nur folgende Typen haben: char, byte, short und int sowie alle Aufzhlungstypen (siehe Abschnitt 3.2.5) und in neuen Java-Versionen (ab JDK7) auch String.
120
Listing 2.23: Note in Text umwandeln switch (note) { case 1: text = "sehr gut"; break; case 2: text = "gut"; break; case 3: text = "befriedigend"; break; case 4: text = "gengend"; break; case 5: text = "nicht gengend"; break; default: text = "FEHLER: keine gltige Note"; }
Listing 2.24: Note in Text umwandeln gefhrliche Variante String text = ""; switch (note) { case 1: text += "mit Auszeichung "; case 2: case 3: case 4: text += "bestanden"; break; case 5: text += "nicht bestanden"; break; default: text += "FEHLER: keine gltige Note"; }
Listing 2.23 gibt ein Anwendungsbeispiel fr eine switch-Anweisung. Nach jeder Sprungmarke knnen beliebig viele Anweisungen stehen. Wichtig ist, dass man nicht auf die break-Anweisung am Ende jeder Anwei-
121
2 Grundlegende Sprachkonzepte
sungsfolge (eventuell abgesehen von der nach der default-Sprungmarke) vergisst. Ohne break wrde die Berechnung nach dem Ende einer Anweisungsfolge mit dem Beginn der nchsten Anweisungsfolge fortgesetzt, nicht wie meist gewnscht mit der ersten Anweisung nach der switchAnweisung. Listing 2.24 ntzt diese Eigenschaft aus und gibt ein Beispiel dafr, wie man es nicht machen soll: Nach der Anweisungsfolge bei Sprungmarke 1 wird mit der Anweisungsfolge bei Sprungmarke 4 fortgesetzt, und wegen der leeren Anweisungsfolgen bei den Sprungmarken 2 bis 4 wird auch fr die Noten von 2 bis 4 zur Anweisungsfolge bei Sprungmarke 4 verzweigt. In Abschnitt 6.4.1 werden wir sehen, warum der veraltete Programmierstil wie in Listing 2.24 gefhrlich ist und nicht verwendet werden soll, obwohl er das erwartete Ergebnis liefert.
2.4 Funktionen
Betrachten wir noch einmal Listing 2.1 in Abschnitt 2.1.1. Stellen wir uns vor, in einem Programm soll nicht nur einmal, sondern an mehreren Stellen der GGT von unterschiedlichen Wertepaaren berechnet werden. Um das zu bewerkstelligen, knnte man die Zeilen 7 bis 17 einfach an die Stellen im Programm kopieren, wo ein GGT berechnet werden soll und dort den Variablen m und n die entsprechenden neuen Startwerte zuweisen. Es ist leicht einzusehen, dass diese Vorgehensweise viele Nachteile hat: Das Programm wird unntig lang, da dieselbe Anweisungssequenz mehrfach vorkommt. Falls wir den Algorithmus zur Berechnung des GGT verndern wollen, mssen wir diese nderung berall durchfhren, wo die entsprechende Anweisungssequenz vorkommt. In allen Programmiersprachen gibt es Sprachmittel, die Anweisungssequenzen, die mehrfach an verschiedenen Stellen gebraucht werden, wiederverwendbar machen. Durch diese Sprachmittel soll nicht nur Codewiederholung vermieden, sondern auch die Mglichkeit zur Strukturierung bzw. Modularisierung des Softwareentwurfs geschaen werden. In Java nennt man die wichtigsten solchen Programmteile Methoden. Eine Methode soll eine einzige, in sich abgeschlossene und gut beschreibbare Teilaufgabe erledigen. Wir beschftigen uns zunchst hauptschlich mit einer speziellen Form von Methoden, die Eingangswerte auf Rckgabewerte abbilden und dazu keine externen Daten verwenden. Diese Methoden sehen die Eingangswerte und sehen und verndern die Variablen, die im Anweisungsblock der
122
2.4 Funktionen
Methode selbst deklariert wurden, aber keine anderen Variablen. Solche Methoden nennt man (reine) Funktionen siehe Abschnitt 1.5. Wie im Lambda-Kalkl werden die Funktionen auf Argumente angewandt. Anwendungen dieser Funktionen haben keine Seiteneekte. In Listing 2.25 denieren wir den GGT-Algorithmus als Methode. Das Programm berechnet, so wie das Programm in Listing 2.1, den GGT, jedoch nicht nur einmal, sondern mehrfach. Die Anweisungen des eigentlichen GGT-Algorithmus stehen in der Denition der Methode ggt in den Zeilen 27 bis 38. Die Methode wird in den Zeilen 5, 8 und 11 aufgerufen. 2.4.1 Methodendention anhand eines Beispiels Eine Methodendenition besteht aus dem Methodenkopf und dem Methodenrumpf. Der Rumpf (auch Body genannt) ist ein Block mit Anweisungen. Zeile 27 in Listing 2.25 enthlt den Kopf der Methode ggt und die nende geschwungene Klammer des Blocks, der den Rumpf darstellt. Die ersten beiden Wrter in Zeile 27 sind Modier, welche die Sichtbarkeit (siehe Abschnitt 3.2.2) und den Gltigkeitsbereich der Methode ndern. Wir werden in Kapitel 3 nher darauf eingehen. Hier nur soviel: Der Modier public bedeutet, dass die Methode entlich sichtbar ist (also von berall aus aufgerufen werden kann), und static bedeutet, dass man keine Objekte braucht um die Methode zu verwenden. Wir werden vorerst alle Methoden mit public und static denieren. Das dritte Wort im Methodenkopf bezeichnet den Typ des Rckgabewertes. Er besagt, dass die Methode eine Zahl vom Typ int als Ergebnis zurckgibt. Danach folgt der Name der Methode, der wie ein Variablenname selbst gewhlt werden kann. In den darauolgenden runden Klammern steht eine Liste von Deklarationen formaler Parameter, die durch Beistriche voneinander getrennt werden. Der Rumpf enthlt die Anweisungen der Methode und berechnet den Rckgabewert. Die letzte Anweisung, die im Rumpf ausgefhrt wird, ist eine return-Anweisung, die die Methode beendet und den rechts davon stehenden Ausdruck als Ergebnis zurckgibt.
Parameter.
Formale Parameter sind spezielle lokale Variablen. Obwohl sie in den runden Klammern des Methodenkopfs deklariert werden, erstreckt sich ihr Gltigkeitsbereich ber den gesamten Methodenrumpf. Im Unterschied zu gewhnlichen lokalen Variablen nehmen formale Pa-
123
2 Grundlegende Sprachkonzepte
Listing 2.25: Deniert eine Methode fr die GGT Funktion 1 public class Euklid2 { 2 3 public static void main(String[] args) { 4 5 int ergebnis = ggt(1027,395); 6 [Link](ergebnis); 7 8 ergebnis = ggt(9,ggt(24,27)); //GGT von 3 Zahlen 9 [Link](ergebnis); 10 11 ergebnis = ggt(ggt(9,24),27); //Ergebnis wie oben 12 [Link](ergebnis); 13 14 int n = 4; 15 int d = 8; 16 17 [Link]("Bruchzahl: "+n+"/"+d); 18 19 ergebnis = ggt(n,d); 20 n /= ergebnis; 21 d /= ergebnis; 22 23 [Link]("gekrzt: "+n+"/"+d); 24 25 } 26 27 public static int ggt(int m, int n) { 28 29 while (m != n) { 30 if (m > n) { 31 m = m - n; 32 } else { 33 n = n - m; 34 } 35 } 36 return m; 37 38 } 39 }
rameter beim Aufruf der Methode Werte entgegen, die der Aufrufer zur Verfgung stellen muss. ber Parameter kann der Aufrufer Daten bermitteln, die die Methode zur Erledigung ihrer Arbeit bentigt.
124
2.4 Funktionen
Die Deklaration eines formalen Parameters hat fast dieselbe Syntax wie die Deklaration einer gewhnlichen einzelnen lokalen Variablen, also Typ gefolgt vom Namen, aber ohne abschlieenden Strichpunkt. In den runden Klammern des Methodenkopfs knnen beliebig viele formale Parameter deklariert sein (keiner, einer oder mehrere), wobei mehrere Deklarationen durch Beistriche voneinander getrennt werden. Ein Aufruf bzw. eine Anwendung der Methode enthlt in runden Klammern eine Liste von Argumenten (auch aktuelle Parameter genannt). Jedes Argument ist durch einen Ausdruck dargestellt, der bei Auswertung einen Wert zurckgibt. Beim Aufruf wird die Liste der formalen Parameter im Methodenkopf mit der Liste der Argumente abgeglichen. Dabei werden die Werte der Argumente der Reihe nach (von links nach rechts) den entsprechenden formalen Parametern zugewiesen siehe Abschnitt 2.4.2. Im Prinzip funktioniert die Parameterbergabe genauso wie bei Anwendung einer -Reduktion im Lambda-Kalkl, wo ein Argument jedes freie Vorkommen des formalen Parameters im Rumpf der Funktion ersetzt. Allerdings knnen Methoden mehrere formale Parameter haben, Funktionen im Lambda-Kalkl nur je einen. Anzahl und Typen der Argumente mssen mit der Anzahl und den Typen der formalen Parameter zusammenpassen: Jedes Argument muss an den jeweiligen formalen Parameter zuweisbar sein. Beispielsweise wird in Zeile 5 von Listing 2.25 ggt mit den beiden Argumenten 1027 und 395 aufgerufen. Dabei erhlt der formale Parameter m von ggt den Wert 1027 und n den Wert 395. Listing 2.26 gibt ein Beispiel fr eine Methode ohne formale Parameter. Runde Klammern muss man auch dann hinschreiben, wenn die Liste der (formalen oder aktuellen) Parameter leer ist, um Methoden syntaktisch von Variablen zu unterscheiden. Solche Methoden sind sinnvoll, wenn sie Seiteneekte haben oder von nicht-lokalen Variablen abhngen. Wir werden solche Methoden spter hug bentigen.
Die return-Anweisung.
Mit Hilfe der return-Anweisung im Methodenrumpf kann ein Wert an den Aufrufer der Methode zurckgeliefert werden. Nach Ausfhrung dieser Anweisung wird der Kontrolluss an der Stelle fortgesetzt, an der die Methode aufgerufen wurde. Ein Methodenaufruf ist ein Ausdruck. Der durch die return-Anweisung vom Methodenaufruf zurckgegebene Wert kann auf die gleiche Art verwendet werden, wie der Wert jeder anderen Art von Ausdruck auch. In Zeile 5 von Listing 2.25 wird der Rckgabewert eines Aufrufs von ggt beispielsweise einer Varia-
125
2 Grundlegende Sprachkonzepte
Listing 2.26: Eine Methode, die ein Rautenmuster ausgibt public static void printDiamond() { [Link](" * "); [Link](" *** "); [Link]("*****"); [Link](" *** "); [Link](" * "); }
blen zugewiesen. Das ist genau der Wert, der in Zeile 36 von ggt zurckgegeben wird, also der grte gemeinsame Teiler von 1027 und 395. In den Zeilen 8 und 11 wird ein von ggt zurckgegebener Wert als Argument in einem neuerlichen Aufruf von ggt verwendet. Es gibt auch Methoden ohne Rckgabewert. Diese Methoden lesen und verndern im Regelfall Variablen, die auerhalb des Methodenrumpfs deklariert wurden. Sie sind also keine reinen Funktionen. Auf Methoden mit Seiteneekten werden wir erst in Abschnitt 2.6.3 genauer eingehen. Hier sei nur erwhnt, dass solche Methoden mit dem Typ void deklariert werden. Im Methodenrumpf kann eine leere return-Anweisung (ohne nachfolgenden Ausdruck) benutzt werden, um die Methodenausfhrung zu beenden. Methoden vom Typ void mssen keine return-Anweisungen enthalten. Ihre Ausfhrung endet, wenn das Ende des Methodenrumpfs erreicht ist. Listing 2.26 zeigt eine solche Methode. Sie hat einen Seiteneekt, nmlich eine Ausgabe auf den Bildschirm. Mehrere Methodendenitionen knnen denselben Methodennamen benutzen, solange sich die Methoden in der Anzahl oder den Typen ihrer formalen Parameter unterscheiden. Man spricht in diesem Fall von berladenen Methoden. Beim Methodenaufruf wird jene Methode mit passendem Namen und zur Argumentliste passender formaler Parameterliste gewhlt. Die Auswahl hngt von der Anzahl und den Typen der formalen Parameter ab, aber nicht von deren Namen. Der Typ des Rckgabewertes, der Methodenname und die Liste der Typen der formalen Parameter heien zusammen Signatur der Methode. Die Methode in Listing 2.26 hat z.B. die Signatur int ggt(int,int). Fr jeden Methodenaufruf stellt der Java-Compiler sicher, dass der Methodenaufruf mit der Signatur der aufgerufenen Methode zusammenpasst.
Methodennamen und Signaturen.
126
2.4 Funktionen
Listing 2.27: Eine Methode fr den GGT von 3 Zahlen public static int ggt(int m, int n, int o) { return ggt(m, ggt(n, o)); }
Wir knnen das Programm aus Listing 2.25 problemlos um eine Methode erweitern, die den GGT von drei Zahlen berechnet. Dazu brauchen wir nur den Code aus Listing 2.27 zwischen den Zeilen 38 und 39 in Listing 2.25 einfgen. Hier wird eine Methode mit drei formalen Parametern deniert, die wiederum auf die bereits denierte Methode mit zwei formalen Parametern zurckgreift und durch verschachtelte Aufrufe den GGT von drei Werten berechnet und zurckliefert. Das Ergebnis dieser Modikation ist in Listing 2.28 dargestellt. Die beiden gleichnamigen Methoden unterscheiden sich in ihrer Signatur. Die Signatur der Methode in den Zeile 24 bis 34 lautet folgendermaen: int ggt(int, int) Die Signatur der Methode in den Zeilen 36 bis 40 ist: int ggt(int, int, int) Wir haben jetzt die Mglichkeit, den GGT von zwei Zahlen oder auch den GGT von drei Zahlen berechnen zu lassen. Wir knnen also zum Beispiel folgende Aufrufe machen: ergebnis = ggt(9,24,27); //Aufruf Zeile 24 ergebnis = ggt(24,27); //Aufruf Zeile 36 Obwohl der Ergebnistyp (= Typ des Rckgabewertes) zur Signatur gehrt, ist es nicht mglich, mehrere Methoden gleichen Namens und gleicher Parametertypen, jedoch unterschiedlicher Ergebnistypen zu denieren. 2.4.2 Beispiel einer Aufrufsequenz In Zeile 5 bis 16 von Listing 2.28 wird die Methode ggt mit jeweils anderen aktuellen Parametern aufgerufen. Rechts davon ist der Kontrolluss schematisch dargestellt. Entlang der horizontalen Achse lsst sich
127
2 Grundlegende Sprachkonzepte
der Zeitpunkt der Ausfhrung einer Anweisung bestimmen. Vertikal wird die Position der momentan auszufhrenden Anweisung im Programmtext dargestellt. Ein schwarzer Strich bedeutet, dass die links davon stehenden Anweisungen zum Zeitpunkt entsprechend der horizontalen Achse ausgefhrt werden. Wenn eine Methode eine weitere Methode aufruft, wird die Ausfhrung der aufrufenden Methode unterbrochen und die aktuelle Position zwischengespeichert, damit (sobald die aufgerufene Methode fertig ist) die Ausfhrung an der richtigen Stelle in der aufrufenden Methode weitergehen kann. Jede Methodenausfhrung wird in Listing 2.28 durch eine eigene Schattierung dargestellt. Wird das Programm gestartet, beginnt dessen Ausfhrung mit der ersten Anweisung im Block der Methode main: 1. Zeile 5: Aufruf mit den aktuellen Parametern 1027 und 395. Damit wird die Methode main unterbrochen und der Kontrolluss springt von Position 1 zur Methode mit der Signatur int ggt(int,int). Mit der return-Anweisung in Zeile 33 kehrt der Kontrolluss zur Zeile 5 zurck und der Rckgabewert der Funktion wird an die Variable ergebnis zugewiesen. 2. Zeile 8: Hier wird die Methode int ggt(int,int,int) aufgerufen (Position 2). Die Ausfhrung wird also in Zeile 36 fortgesetzt. 3. Zeile 38: Damit der return-Ausdruck berechnet werden kann, muss die Ausfhrung der Methode hier unterbrochen werden. Zunchst wird die Funktion ggt(n,o) ausgewertet (Sprung bei Position 3), und erst wenn dieser Funktionswert geliefert wurde, kann das Gesamtergebnis durch Auswertung der Funktion ggt(m, ggt(n,o)) durch einen erneuten Aufruf von int ggt(int,int) berechnet werden (Position 4). Von der return-Anweisung in Zeile 38 wird dann das Ergebnis zurckgeliefert. 4. Zeile 16: Hier wird die Methode das letzte Mal mit (den Werten von) n und d aufgerufen (Position 5). Bei jedem Methodenaufruf wird fr die Dauer der Methodenausfhrung ein Speicherbereich reserviert um dort unter anderem lokale Variablen (einschlielich der Parameter) der Methode abzulegen. Dies gilt auch dann, wenn es mehrere einander berlappende Ausfhrungen derselben Methode
128
2.4 Funktionen
Listing 2.28: Beispiel fr Aufrufsequenz 1 public class Euklid2 { 2 3 public static void main(String[] args) { 4 5 int ergebnis = ggt(1027,395); 6 [Link](ergebnis); 7 8 ergebnis = ggt(9,24,27); 9 [Link](ergebnis); 10 11 int n = 4; 12 int d = 8; 13 14 [Link]("Bruch: "+n+"/"+d); 15 16 ergebnis = ggt(n,d); 17 n /= ergebnis; 18 d /= ergebnis; 19 20 [Link]("gekrzt: "+n+"/"+d); 21 22 } 23 24 public static int ggt(int m, int n) { 25 26 while (m != n) { 27 if (m > n) { 28 m = m - n; 29 } else { 30 n = n - m; 31 } 32 } 33 return m; 34 } 35 36 public static int ggt(int m, int n, int o) 37 { 38 return ggt(m, ggt(n, o)); 39 } 40 }
Zeit
Kontrolluss
129
2 Grundlegende Sprachkonzepte
angelegte Variablen
3
m n
4
m n
5
m n
1
m n
m n o ergebnis
n d
Zeit
gibt. Wir betrachten hier nur sequentielle Programme, keine parallelen Programme, d.h. zu jedem Zeitpunkt wird nur eine Anweisung ausgefhrt. In Abbildung 2.29 sind die Speicherbereiche der ausgefhrten Methoden mit ihren lokalen Variablen schematisch dargestellt. Die Speicherbereiche aufgerufener Methoden liegen oberhalb jener der aufrufenden Methoden, siehe Aufrufpfeile. Nach Aufruf 3 (gestrichelte Linie) sind nur die zwei Variablen der obersten Methode zugreifbar. Die aufrufenden Methoden sind unterbrochen, bis die aufgerufenen Methoden fertig sind, aber die Variablen der aufrufenden Methoden existieren auch whrend der Unterbrechung. Beispielsweise existieren die bei Aufruf 2 angelegten Variablen m, n und o gleichzeitig mit den bei Aufruf 3 angelegten Variablen m und n. Nach der Rckkehr aus Aufruf 3 sind die Variablen m, n und o wieder zugreifbar, bis zum Aufruf 4 und nach der Rckkehr aus Aufruf 4 bis zur Rckkehr aus Aufruf 2. Die gerade ausgefhrte Methode kann nur auf eigene lokale Variablen zugreifen, nicht auf die unterbrochener (auf Ergebnisse wartender) Methoden. 2.4.3 Rekursive Methoden Eine Methode kann sich auch selbst aufrufen. Man spricht in diesem Fall von Rekursion bzw. von einem rekursiven Aufruf. Wie bei jedem Methodenaufruf wird die Ausfhrung der aufrufenden Methode unterbrochen, bis die Ausfhrung der aufgerufenen Methode beendet ist. Die neue Methodenausfhrung hat einen eigenen Speicherbereich fr ihre lokalen Va-
130
2.4 Funktionen
riablen. Falls die Methode ausschlielich lokale Variablen benutzt (dazu zhlen auch ihre formalen Parameter), also keine Seiteneekte hat, knnen sich die Ausfhrungen nicht gegenseitig beeinussen. Die aufrufende Methode wartet, bis die aufgerufene Methode fertig ist. Das Ergebnis des rekursiven Aufrufs kann dann weiter verwendet werden. Listing 2.30 zeigt eine Variante des Programms aus Listing 2.28. Die Funktionalitt der Methode int ggt(int,int) ist dieselbe geblieben. Gendert hat sich nur die Implementierung, die jetzt rekursiv ist. Auf den ersten Blick wirkt es seltsam, dass bei einer Rekursion die aufrufende und aufgerufene Methode genau dieselbe ist. Das hat zur Folge, dass dieselben Programmzeilen wiederholt ausgefhrt werden. In dieser Hinsicht bewirkt die Rekursion dasselbe wie eine Schleife: Es wird ein Anweisungsblock wiederholt ausgefhrt. Die formalen Parameter sowie alle anderen lokalen Variablen werden fr jede rekursive Ausfhrung gesondert angelegt. Wir mssen beim Programmieren selbst dafr sorgen, dass die Werte der Argumente bei jedem rekursiven Aufruf anders sind. Wre dem nicht so, riefe sich eine Methode mit immer wieder denselben Argumenten auf und es kme zu einer Endlosrekursion das Pendant zur Endlosschleife. Damit es nicht zu einer Endlosrekursion kommt, muss es einen Rekursionsanfang geben, also einen Fall, bei dem ein Methodenaufruf zu keinem weiteren Aufruf fhrt, sondern direkt ein Ergebnis liefert. In Listing 2.30 geschieht das, wenn m und n denselben Wert haben. In diesem Fall ist der GGT genau dieser Wert. Es gilt also ggt(n, n) = n. Im Vergleich mit Listing 2.28 sehen wir, dass auch hier die Schleife abgebrochen wird, wenn dieser Fall eintritt. Dann wird einfach der Wert zurckgeliefert. In jedem anderen Fall gilt ggt(m, n) = ggt(n m, n) wenn n m > 0, sonst ggt(m, n) = ggt(m, m n) wenn m n > 0. Diese mathematischen Gleichungen erkennt man sowohl in der iterativen Lsung aus Listing 2.28, wo im nchsten Schleifendurchlauf entweder mit den Werten m n, n oder mit m, n m weitergearbeitet wird, als auch in der rekursiven Lsung aus Listing 2.30, wo der rekursive Aufruf mit diesen Werten erfolgt. Ein rekursiver Aufruf ist also ein Aufruf derselben Methode mit neuen Argumenten. Man erkennt anhand der Beispiele, dass Rekursion und Iteration etwa dasselbe machen. Wir knnen mit einer Schleife also nicht mehr berechnen, als wir auch rekursiv berechnen knnen. Tatschlich ist die Schleife als Kontrollstruktur fr die Turing-Vollstndigkeit einer Programmiersprache, in der es Funktionen gibt, nicht notwendig.
131
2 Grundlegende Sprachkonzepte
Listing 2.30: GGT mit rekursivem Algorithmus 1 public class Euklid2 { 2 3 public static void main(String[] args) { 4 5 int ergebnis = ggt(1027,395); 6 [Link](ergebnis); 7 8 ergebnis = ggt(9,24,27); 9 [Link](ergebnis); 10 11 int n = 4; 12 int d = 8; 13 14 [Link]("Bruchzahl: "+n+"/"+d); 15 16 ergebnis = ggt(n,d); 17 n /= ergebnis; 18 d /= ergebnis; 19 20 [Link]("gekrzt: "+n+"/"+d); 21 22 } 23 24 public static int ggt(int m, int n) { 25 26 if (m == n) { 27 return m; 28 } 29 30 if (m > n) { 31 return ggt(m - n, n); 32 } else { 33 return ggt(m, n - m); 34 } 35 } 36 37 public static int ggt(int m, int n, int o) { 38 39 return ggt(m, ggt(n, o)); 40 41 } 42 }
132
2.4 Funktionen
2.4.4 Zusicherungen Der GGT-Algorithmus setzt voraus, dass alle aktuellen Parameter grer 0 sind, also m > 0 und n > 0 gilt. Damit die Methode int ggt(int,int) das richtige Ergebnis liefert, mssen diese Bedingungen beim Aufruf der Methode erfllt sein. Andernfalls verhlt sich die Methode nicht wie erwartet. Wenn beispielsweise einer der beiden aktuellen Parameter den Wert 0 hat, gert das Programm in eine Endlosschleife bzw. Endlosrekursion. Die Bedingung m > 0 und n > 0 ist eine Zusicherung. Sie kann als Boolescher Ausdruck angeschrieben werden. Wir mssen beim Programmieren dafr sorgen, dass dieser Ausdruck stets wahr ist. Wie bereits in Abschnitt 1.5.4 erwhnt, gibt es mehrere Mglichkeiten, Zusicherungen darzustellen, z.B. durch einen Kommentar oder die assert-Anweisung. Bezogen auf eine Methode (oder allgemeiner jede beliebige Folge von Anweisungen) kann man zumindest zwei Arten von Zusicherungen unterscheiden: Vorbedingungen und Nachbedingungen. In Abschnitt 3.5.1 werden wir noch weitere Arten kennenlernen. Wir betrachten nun die Zustnde von Variablen und wie diese durch eine Folge von Anweisungen verndert werden. Die zu einem bestimmten Zeitpunkt aktuellen Werte aller im Programm verwendeten Variablen nennt man den Programmzustand. Bei Ausfhrung verndert eine Anweisungsfolge den Programmzustand. Die Anweisungen fhren das Programm von einem Anfangszustand (vor Ausfhrung) in einen Endzustand (nach Ausfhrung) ber. Wir haben hier eine Beziehung zwischen drei Einheiten: Anweisungsfolge, Anfangszustand und Endzustand. Wenn wir zwei Einheiten kennen, knnen wir eine dritte Einheit ermitteln. Aus dem Anfangszustand und der Anweisungsfolge knnen wir problemlos (durch eine gedachte Ausfhrung der Anweisungsfolge) den Endzustand herleiten. Falls wir den Anfangs- und den Endzustand kennen, wissen wir, was die Anweisungsfolge macht. Es gibt zwar wahrscheinlich viele Mglichkeiten, diese Anweisungsfolge auszudrcken, aber die Semantik der Anweisungsfolge ist klar. Wir knnen sogar aus der Anweisungsfolge und dem Endzustand einen mglichen Anfangszustand ableiten, also die Anweisungen gedanklich in umgekehrter Richtung ausfhren. Meistens kennen wir die Programmzustnde nicht vollstndig, sondern nur teilweise. Wir kennen also nur einige Eigenschaften der Zustnde, etwa m > 0 und n > 0. Auch dieses unvollstndige Wissen knnen wir sinnvoll einsetzen. Beispielsweise kennen wir eine Anweisungsfolge S (als Abkrzung von Sequenz), eine Eigenschaft P des Anfangszustands (das ist
133
2 Grundlegende Sprachkonzepte
eine Vorbedingung ) und eine Eigenschaft Q des Endzustands (das ist eine Nachbedingung ). Diese Einheiten sollen in einer Beziehung zueinander stehen. Wenn wir P und Q gnstig whlen, dann knnen wir Q aus P und S ableiten. In diesem Fall beschreiben P und Q alle wichtigen Eigenschaften der Semantik von S . Wir wollen die Vorbedingung P so festlegen, dass sie einen korrekten Ablauf von S ermglicht. Ist P erfllt, dann muss nach Ausfhrung von S auch die Nachbedingung Q erfllt sein. Man kann diese Zusammenhnge formal als Hoare-Tripel ausdrcken: {P } S {Q} Eine Vorbedingung P gibt an, unter welcher Bedingung S ein sinnvolles Verhalten zeigt, also korrekt funktioniert. Beispielsweise ist m > 0 und n > 0 eine Vorbedingung fr die Methode int ggt(int,int), wobei S dem Rumpf der Methode entspricht. Fr die Einhaltung der Vorbedingung einer Methode ist der Aufrufer (also der Programmteil, der die Methode aufruft) zustndig, da die Methode selbst keine Mglichkeit hat, die Einhaltung der Vorbedingung zu gewhrleisten. Der Aufrufer muss also dafr sorgen, dass der Programmzustand zum Zeitpunkt des Aufrufs die Vorbedingung erfllt. Eine Nachbedingung Q ist eine Eigenschaft des Programmzustands, die nach Ausfhrung der Methode erfllt sein muss. Eine sinnvolle Nachbedingung der Methode int ggt(int,int) besagt, dass der Rckgabewert dem GGT der beim Aufruf bergebenen aktuellen Parameter entspricht. Falls die Vorbedingung zum Zeitpunkt des Aufrufs erfllt ist, die Nachbedingung bei der Rckkehr jedoch nicht, so ist die Methode fehlerhaft. Ist jedoch bereits die Vorbedingung zum Zeitpunkt des Aufrufs nicht erfllt, dann drfen wir nicht erwarten, dass die Nachbedingung bei der Rckkehr aus der Methode erfllt ist. Die Methode int ggt(int,int) ist daher nicht fehlerhaft, obwohl sie bei Aufruf mit 0 als aktuellem Parameter in eine Endlosschleife oder Endlosrekursion kommt und vielleicht das gesamte Programm zum Absturz bringt. Fehlerhaft ist in diesem Fall nicht die Methode selbst, sondern der Aufruf der Methode. Vor- und Nachbedingungen knnen beispielsweise dazu verwendet werden, die Korrektheit einer Methode zu berprfen. Ist die Vorbedingung P erfllt, dann ist der Methodenrumpf S dann korrekt, wenn man aus P und S die Nachbedingung Q ableiten kann. Als Beispiel versuchen wir die Korrektheit der iterativen Variante von int ggt(int,int) zu beweisen. Dazu sind zwei Dinge zu zeigen:
134
(a) Stehen die formalen Parameter m und n fr zwei beliebige Zahlen mit m > 0 und n > 0, dann terminiert die Methode; das heit, die Ausfhrung endet nach endlich vielen Schritten. (b) Terminiert die Methode, so ist der zurckgelieferte Wert der GGT von m und n; das heit, das Ergebnis ist korrekt. Anfangs sind beide Zahlen grer 0. In jeder Iteration wird die kleinere Zahl von der greren abgezogen. Der Fall, bei dem eine Zahl (kleiner oder) gleich 0 wird, kann nur eintreten, wenn m == n gilt, was aber schon vorher zum Abbruch der Schleife fhrt. Daher bleiben beide Zahlen stets grer 0, und in jeder Iteration wird entweder m oder n um mindestens 1 verkleinert und die andere Zahl bleibt gleich. Auch m + n wird in jeder Iteration um mindestens 1 kleiner (grobe Abschtzung) und die Abbruchbedingung m == n nach sptestens m + n Iterationen wahr, und Schleife sowie Methode terminieren nach endlich vielen Schritten.
Beweis von (a):
Wir setzen zur Vereinfachung zwei Eigenschaften jedes grten gemeinsamen Teilers voraus, ohne sie zu beweisen:
Beweis von (b):
Wenn m > n und n > 0, dann gilt ggt(m, n) = ggt(m n, n). Es gilt ggt(m, n) = ggt(n, m) (Kommutativitt). Nach diesen Voraussetzungen wissen wir, dass der GGT von m und n in jeder Iteration durch den Schleifenrumpf gleich bleibt (obwohl sich m oder n ndert). Aus dem Beweis von (a) ist bekannt, dass nach endlich vielen Schritten m == n erfllt ist. Folglich ist nach Abbruch der Schleife m der GGT von m und n und nach obigen berlegungen auch von den ursprnglichen aktuellen Parametern des Methodenaufrufs. Auf hnliche Weise lsst sich zeigen, dass auch die rekursive Variante von int ggt(int,int) korrekt ist. Im Beweis wird die Vorbedingung m > 0 und n > 0 bentigt. Daher wird bei Verletzung der Vorbedingung auch die Nachbedingung nicht gelten.
135
2 Grundlegende Sprachkonzepte
rekursiv
angelegte Variablen
iterativ
3
m n m n m n angelegte Variablen m n
2
m n
Zeit
Zeit
die wiederholte Ausfhrung eines Blocks, entweder eines Methodenrumpfs oder eines Schleifenrumpfs. Die Schleife ist eine Kontrollstruktur, die in vielen Fllen eine Vereinfachung gegenber der Rekursion ermglicht. Unbedingt notwendig sind Schleifen in einer Programmiersprache aber nicht. Viele Leute betrachten Schleifen als ezienter und einfacher verstndlich, weil die Iterationen lokal innerhalb einer Methode stattnden und man sich die Komplexitt wiederholter Methodenaufrufe erspart. Dazu trgt die Tatsache bei, dass in einer Schleife immer wieder dieselben Variablen benutzt werden, whrend rekursive Aufrufe stets neue Variablen einfhren. Abbildung 2.31 veranschaulicht dies durch Gegenberstellung der ntigen Speicherbereiche bei rekursiver und iterativer Ausfhrung von int ggt(int,int) links die rekursive und rechts die iterative Variante. Jede Methodenausfhrung hat ihren eigenen Speicherbereich. Die Speicherbereiche aufgerufener Methoden liegen oberhalb jener der aufrufenden Methoden (siehe Aufrufpfeile). Fr jeden rekursiven Aufruf werden weitere Variablen angelegt. Die iterative Variante kommt dagegen mit einer einzigen Methodenausfhrung und daher weniger Variablen aus. Trotzdem ist Rekursion gegenber Schleifen oft vorteilhaft, wie wir in Kapitel 4 sehen werden: Nach der Rckkehr aus einem rekursiven Aufruf kann man noch immer auf die vor dem rekursiven Aufruf in lokalen Variablen abgelegten Werte zugreifen. Schleifen-Iterationen weisen dagegen neue Werte an die Variablen zu, sodass die alten Werte verlorengehen. Zur Berechnung des GGT werden die alten Werte nicht bentigt. Schleifen und Rekursion bentigt man hug zusammen mit Datenstrukturen. Daher fhren wir Schleifen und Arrays gemeinsam ein.
136
2.5.1 while- und do-while-Schleifen Die einfachste Form einer Schleife in Java, die while-Schleife, haben wir schon verwendet. Sie hat folgende Syntax: while ( boolescherAusdruck ) Anweisung Der Boolesche Ausdruck in den runden Klammern ist die Schleifenbedingung. Zu Beginn wird die Schleifenbedingung ausgewertet, und wenn sie true liefert, wird Anweisung ausgefhrt. Sobald die Anweisung ausgefhrt wurde, wird die Schleifenbedingung erneut ausgewertet, und wenn sie noch immer true liefert, wird Anweisung erneut ausgefhrt. Das wird solange wiederholt, bis die Schleifenbedingung zu false ausgewertet wird. Ist das der Fall, wird die Schleife beendet und die Programmausfhrung mit der nchsten Anweisung nach der while-Schleife fortgesetzt. Ein Beispiel fr den Einsatz einer while-Schleife ist in Listing 2.28 in den Zeilen 26 bis 32 zu sehen. Anstelle einer einzelnen Anweisung (in diesem Beispiel eine if-Anweisung) verwendet man in der Regel einen Block. Die einzelne Anweisung bzw. der Block wird Schleifenrumpf genannt. Das Schlsselwort while und die Schleifenbedingung in runden Klammern bilden zusammen den Schleifenkopf. Um keine Endlosschleife zu erzeugen, muss der Schleifenrumpf die Auswertung der Schleifenbedingung beeinussen. Jede Ausfhrung des Schleifenrumpfs ist eine Iteration. Eine whileSchleife fhrt den Schleifenrumpf meist mehrmals aus (= mehrere Iterationen), jedoch kann es auch sein, dass keine einzige Iteration erfolgt, wenn die Schleifenbedingung gleich bei der ersten Auswertung false ergibt. Die do-while-Schleife ist eine Variante, bei der der Schleifenrumpf mindestens einmal ausgefhrt wird. Dies ist an der Syntax erkennbar: do Anweisung while ( boolescherAusdruck ) Die Schleifenbedingung wird erst nach jeder Iteration ausgewertet. Wenn die Auswertung true liefert, erfolgt eine weitere Iteration. In der Praxis werden do-while-Schleifen deutlich seltener verwendet als while-Schleifen. Dafr gibt es einen Grund: Hug betrachtet man die Schleifenbedingung einer while-Schleife als Vorbedingung fr den Schleifenrumpf. Aufgrund der Semantik der while-Schleife ist diese Bedingung zu Beginn des Rumpfes in jeder Iteration erfllt. Beispielsweise wissen wir
137
2 Grundlegende Sprachkonzepte
bei der Berechnung des GGT, dass m zu Beginn jeder Iteration ungleich n ist, eine Eigenschaft, die wir auch zum Beweis der Termination der Schleife bentigen. In einer do-while-Schleife kann man die Schleifenbedingung nicht als Vorbedingung betrachten. 2.5.2 Arrays Ein Array ist ein Objekt, das aus mehreren Komponenten zusammengesetzt ist. Die Komponenten heien Elemente und sind alle vom selben Datentyp (Elementtyp). Array-Typen sind Referenztypen. Eine Variable eines Array-Typs ist eine Referenzvariable, sie speichert also eine Referenz auf ein Array-Objekt (siehe Abschnitt 2.1.3 ber Referenztypen). Ein Array kann folgendermaen erzeugt werden: int[] ia; ia = new int[4]; In der ersten Zeile wird eine Referenzvariable fr ein Array erzeugt, wobei durch die leeren eckigen Klammern [] klar gemacht wird, dass es sich um eine Array-Variable handelt und der Elementtyp int ist. Die Anzahl der Elemente wird bei der Deklaration einer Array-Variable nicht angegeben. In der zweiten Zeile wird zunchst der Ausdruck new int[4] rechts vom Zuweisungsoperator ausgewertet. Mit Hilfe des Operators new wird ein neues Objekt erstellt, und int[4] legt fest, dass es sich dabei um ein Array handelt, das vier Elemente vom Typ int aufnehmen kann. Beim Erstellen des Arrays werden diese Elemente mit dem Default-Wert 0 initialisiert. Allgemein werden Arrays durch Ausdrcke dieser Form erzeugt: new Elementtyp[int-Ausdruck ]; Als Elementtyp kann jeder Typ verwendet werden, also sowohl ein elementarer Typ als auch ein Referenztyp. Der Elementtyp kann auch selbst wieder der Typ eines Arrays sein, wodurch ein mehrdimensionales Array entsteht siehe unten. Der Ausdruck in den eckigen Klammern muss eine Zahl vom Typ int grer 0 zurckgeben, die Anzahl der Elemente. Der new-Operator liefert eine Referenz auf das neu erzeugte Array-Objekt zurck. Diese wird in obigem Beispiel der Variable ia zugewiesen siehe Abbildung 2.32. Die Elemente eines neu erzeugten int-Arrays werden automatisch mit dem Wert 0 initialisiert.
138
ia
ref
Die Lnge (oder Gre) des Arrays entspricht der Anzahl der Elemente. Die Elemente des Arrays knnen ber einen ganzzahligen Index angesprochen werden, wobei der Index des ersten Elements 0 ist. Ist n die Lnge des Arrays, werden die Elemente von 0 bis n 1 durchgezhlt. Der Zugri auf die Elemente ist ber die Array-Variable mglich. Beispielsweise erfolgt der Zugri auf das dritte Element des oben erzeugten Arrays mit ai[2]. Die Elemente sind durch Zuweisungen nderbar: ia[2] = 8; //drittes Element ia[3] = 10; //viertes Element ia[0] = 45; //erstes Element Ausdrcke wie ai[2] knnen also als L-Werte verwendet werden. Als R-Wert (also irgendwo anders als links von einem Zuweisungs-Operator verwendet) gibt der Ausdruck ai[2] das dritte Element zurck, nach Ausfhrung obiger Zuweisungen die Zahl 8. Es gibt eine weitere Mglichkeit zur Erzeugung eines Arrays: int[] ia; ia = new int[] {45, 0, 8, 10}; Auf der rechten Seite der Zuweisung wird das Array durch die Angabe einer Initialisierungsliste angelegt und gleichzeitig mit Werten initialisiert. Die Lnge des Arrays ergibt sich aus der Lnge der Initialisierungsliste. Die Initialisierungsliste wird auch Array-Literal genannt, weil sie wie ein Literal das ganze Array direkt speziziert. Das Ergebnis ist dasselbe wie in obigem Beispiel (Erzeugung mit new int[4] und darauf folgender Initialisierung durch Zuweisungen). Bei der Erzeugung des Arrays wird dessen Lnge festgelegt. Sie kann ber die Objektvariable length (siehe Kapitel 3) abgefragt werden. Die Lnge des Arrays in ia lsst sich also durch [Link] abfragen. Links vom Punkt-Operator . muss dafr ein Ausdruck stehen, der eine Referenz
139
2 Grundlegende Sprachkonzepte
Listing 2.33: Erweiterung des Arrays ia int[] ia = {45, 0, 8, 10}; // ... // hier wird nun mehr Platz gebraucht und daher vergrert: // Schritt 1: temporre Hilfsvariable mit neuem Array definieren int[] iaLarger = new int[[Link]+1]; // Schritt 2: Elemente ins neue Array kopieren int i = 0; while(i < [Link]) { iaLarger[i] = ia[i]; i++; } // ab nun neues Array benutzt; bisheriges Array geht verloren // Schritt 3: Zuweisung ia = iaLarger; //ia hat nun Lnge 5. // ia hat Lnge: 4
auf ein Array liefert. So ergibt new int[3].length den Wert 3. (Die Referenz auf das neue Array geht dabei aber verloren.) Die Lnge eines Arrays ist nach dessen Erzeugung nicht mehr nderbar. Trotzdem kann der Fall eintreten, dass in einem Array mehr Platz bentigt wird. In diesem Fall muss ein neues, greres Array erzeugt werden, und die Elemente mssen einzeln kopiert werden, wie in Listing 2.33 gezeigt. Die Auswirkungen der einzelnen Schritte werden in Abbildung 2.34 veranschaulicht. Im letzten Schritt wird die Referenz auf das Array der Lnge 5 an die Variable ia zugewiesen. Ab diesem Zeitpunkt ist ber ia nur mehr das vergrerte Array zugreifbar, und das ursprngliche Array der Lnge 4 kann nicht mehr verwendet werden. Der Speicherbereich dafr wird spter irgendwann automatisch freigegeben. Die Hilfsvariable iaLarger wird nach der Zuweisung auch nicht mehr bentigt. Ein Array dessen Elementtyp wieder ein Array ist, wird als mehrdimensionales Array bezeichnet. Beispielsweise kann man eine Matrix von Zahlenwerten durch ein mehrdimensionales Array darstellen: int[][] matrix = new int[3][4]; matrix[2][1] = 5;
140
Das hier erzeugte Array besteht besteht aus 3 Elementen vom Typ int[]. Die Elemente sind also wieder Referenzvariablen siehe Abbildung 2.35. Mehrdimensionale Arrays mssen nicht unbedingt eine rechteckige Form haben. Man kann die einzelnen Elemente auch mit unterschiedlich langen Arrays initialisieren. In diesem Beispiel wird zunchst die Lnge entlang der zweiten Dimension oen gelassen (man spricht von oenen Arrays ): //zweite Lnge bleibt offen int[][] dreieck = new int[3][]; dreieck[0] = new int[3]; dreieck[1] = new int[2]; dreieck[2] = new int[1];
141
2 Grundlegende Sprachkonzepte
Index: 0 Index: 1 Index: 2 Index: 3
Index: 2
5
Index: 1
0
Index: 2
0
Index: 3
Index: 1
Index: 0
0
Index: 1
0
Index: 2
0
Index: 3
Index: 0
Index: 0
matrix
ref
Dasselbe Array kann auch so erzeugt werden: int[][] dreieck = new int[][] {{0,0,0},{0,0},{0}}; Auf einzelne Elemente eines mehrdimensionalen Arrays greift man zu, indem man einen Index in jeder Dimensionen angibt. Beispielsweise liefert dreieck[1][1] den Wert 0. Wenn man wie in dreieck[1] weniger Indexe angibt, erhlt man als Ergebnis eine Referenz auf ein Array, das einen Teil der gesamten Datenstruktur darstellt in diesem Beispiel auf ein eindimensionales Array der Lnge 2. Ohne Index erhlt man wie in dreieck eine Referenz auf das gesamte mehrdimensionale Array. 2.5.3 for-Schleifen Die for-Schleife ist eine syntaktische Vereinfachung der while-Schleife fr den in der Praxis sehr hug auftretenden Fall, dass Laufvariablen benutzt werden. Unter einer Laufvariablen versteht man eine Variable, die in einer Schleife als Index fr den Zugri auf Array-Elemente dient. In jeder Iteration enthlt die Variable einen anderen Wert, man luft also quasi in einer Schleife ber die Elemente eines Arrays. Die Variable i in Listing 2.33 kann als Laufvariable angesehen werden. Beginnend mit dem Wert 0 wird ihr Wert am Ende jeder Iteration um 1 erhht und mit i als Index auf die Arrays iaLarger und ia zugegrien. Somit luft die Variable Iteration fr Iteration von 0 bis zur Lnge von ia. Die Syntax der for-Anweisung ist auf den ersten Blick recht komplex:
142
Listing 2.36: Schritt 2 aus Listing 2.33 als for-Schleife //Schritt 2: Elemente ins neue Array kopieren for (int i = 0; i < [Link]; i++) { iaLarger[i] = ia[i]; }
for ( Init ; boolescherAusdruck ; Aktualisierung ) Anweisung Allerdings stellt sie im Wesentlichen nur eine abgekrzte Schreibweise fr folgenden, semantisch quivalenten Block dar: { Init ; while ( boolescherAusdruck ) { Anweisung Aktualisierung ; } } Dabei ist Init ein beliebiger Ausdruck, der in der Praxis meist zur Deklaration und/oder Initialisierung von Laufvariablen verwendet wird. Aktualisierung ist ein beliebiger Ausdruck, der meist zur nderung des Wertes von Laufvariablen dient. Schritt 2 aus Listing 2.33 lsst sich bersichtlicher als for-Schleife ausdrcken, wie in Listing 2.36 gezeigt. In diesem Fall besteht sowohl Init als auch Aktualisierung aus je einem einzigen Ausdruck. Falls mehrere Initialisierungen bzw. Aktualisierungen durchgefhrt werden sollen, mssen die entsprechenden Ausdrcke durch Kommata voneinander getrennt werden. In Init darf allerdings nur eine Denition vorkommen. Es ist daher nicht mglich, Variablen mit unterschiedlichen Typen zu denieren, aber mehrere Variablen desselben Typs kommen hug vor: for (int n=0, k=5; k >= 0; n++, k--) { ... } Sowohl Init als auch boolescherAusdruck und Aktualisierung kann leer bleiben. Fehlen Init oder Aktualisierung, fehlen auch die entsprechenden
143
2 Grundlegende Sprachkonzepte
Listing 2.37: Ausgabe eines Arrays mit einer for-each -Schleife int[] ai = {45,0,8,10}; for (int wert: ai) { [Link](wert); }
Anweisungen in der quivalenten while-Schleife. Fehlt boolescherAusdruck, wird als Ersatz true verwendet. Die Anweisung for(;;){...} ist daher quivalent zu while(true){...}. Eine erweiterte Variante der for-Anweisung steht in neueren JavaVersionen (seit JDK 5.0) zur Verfgung. Beispielsweise gibt die Schleife in Listing 2.37 alle Elemente des Arrays ai auf den Bildschirm aus. Im Schleifenkopf wird zunchst eine Variable mit demselben Typ wie der Elementtyp des Arrays, das durchlaufen werden soll, deklariert. Nach dem Doppelpunkt folgt ein Ausdruck, der eine Referenz auf das zu durchlaufende Array liefert. Bei jedem Schleifendurchlauf wird das nchste Element an die vor dem Doppelpunkt denierte Variable zugewiesen. Die Reihenfolge ist dabei immer aufsteigend, von 0 bis zum hchsten Index. Es ist also nicht mglich, das Array beispielsweise rckwrts zu durchlaufen oder Elemente auszulassen. Diese Form der Schleife kann so gelesen werden: Fr jedes Element von ai . . . Daher wird dafr manchmal auch die Bezeichnung for-eachSchleife benutzt. 2.5.4 Beispiel: Pascalsches Dreieck Anhand des folgenden Beispiels soll der Einsatz von Rekursion und Schleifen zusammen mit Arrays veranschaulicht werden. Das Pascalsche Dreieck ist eine geometrische Darstellung der Binomialkoezienten, welche die rekursive Formel fr deren Berechnung widerspiegelt. Folgende Formel berechnet Binomialkoezienten n : k n n1 n1 = + k k k1 und fr n > k > 0
144
0 1 n = = = 1. 0 0 n Ordnet man die Binomialkoezienten als Eintrge im Pascalschen Dreieck an, so lassen sich die Werte leicht bestimmen: Der erste und letzte Eintrag einer Zeile ist immer 1. Alle anderen Eintrge ergeben sich aus der Summe der zwei darberstehenden Eintrge:
0 0 1 0 2 0 3 0 4 0 4 1 3 1 4 2 2 1 3 2 4 3 1 1 2 2 3 3 4 4
1 1 1 1 4 1 3 6 2 3 4 1 1 1 1
0 1 2 3 4
0 H H H 1 1 1 1 1
1 1 2 3 4
2 3 4
1 3 1 6 4 1
Wir wollen zunchst ein Java-Programm erstellen, das das Pascalsche Dreieck unter Verwendung einer rekursiv denierten Funktion ermittelt und ausgibt. Listing 2.38 zeigt den entsprechenden Programmcode. Wird das Programm ausgefhrt, wartet es zunchst in Zeile 7 auf die Eingabe einer Zahl, die die Anzahl der Zeilen des Pascalschen Dreiecks angibt. Diese Zahl wird dann der Variablen zeilen zugewiesen. In den Zeilen 9 und 10 werden die Variablen n und k mit dem Wert 0 initialisiert. Der nun folgende Block besteht aus 2 verschachtelten Schleifen. In der ueren Schleife wird n immer um eins erhht, damit eine Zeile nach der anderen erzeugt werden kann. In jeder Zeile muss fr die einzelnen Eintrge der Spaltenindex k von 0 bis Zeilenende n inkrementiert werden. Zunchst wird k = 0 gesetzt. Bei jedem Durchlauf der inneren Schleife wird ein weiterer Eintrag der Zeile ergnzt (direkt ausgegeben). Um den Eintrag an der Stelle n,k im Dreieck zu berechnen, wird die Funktion binom benutzt. Damit die Eintrge unterscheidbar bleiben, wird ein
145
2 Grundlegende Sprachkonzepte
Listing 2.38: Pascalsches Dreieck mit rekursivem Algorithmus 1 import [Link]; 2 3 public class Pascal { 4 5 public static void main(String[] args) { 6 7 Scanner sc = new Scanner([Link]); 8 9 int zeilen = [Link](); 10 11 for (int n = 0; n < zeilen; n++) { 12 13 for (int k = 0; k <= n; k++) { 14 [Link](binom(n, k) + " "); 15 } 16 17 [Link](); 18 } 19 20 } 21 22 public static int binom(int n, int k) { 23 24 if (k==0 || k==n) { 25 return 1; 26 } else { 27 return binom(n-1, k-1) + binom(n-1, k); 28 } 29 30 } 31 }
Leerzeichen an jeden Eintrag angehngt (Verkettung mit +). Der gesamte obige Vorgang wird wiederholt, bis das Zeilenende erreicht ist. Sobald k>n gilt, wird die Schleife beendet. Die Zeile ist damit fertig. Im ueren Schleifenrumpf wird ein Zeilenvorschub ausgegeben, damit die nchste Ausgabe in der nchsten Zeile erfolgt. Der Zeilenindex wird danach um 1 erhht und es wird geprft, ob eine weitere Zeile erzeugt werden soll. Die Berechnung erfolgt in Listing 2.38 rekursiv. Vergleichen Sie die Implementierung von binom mit oben angegebener Formel fr Binomialkoezienten. Der Algorithmus ist eine direkte Umsetzung der Formel.
146
@ k 0 1 2 3 4 n@ @
@ k 0 1 2 3 4 n@ @
0 1 2 3 4
1 1 1
@ I I @ 6 @6
0 1 2 3 4
1 1 1
@ I I @ I @ I @ 6 6 6 6 @
1 2 1
1 3 3 1 1 4 6 4 1
@ I @6
@ I I @ @6 @6
1 2 1
1 3 3 1
@ I I @ 6 I @ I @ 6 @6 @6
1 4 6 4 1
@ I I @ I @ @6 @6 @6
Die rekursive Methode hat vor allem einen Nachteil: Sie berechnet Teile des Ergebnisses immer wieder neu und hat daher eine viel lngere Laufzeit als ntig. Abbildung 2.39 veranschaulicht die Anzahl der rekursiven Aufrufe fr binom(4,2) auf der linken Seite sowie der gesamten fnften Zeile auf der rechten Seite durch Pfeile. Man sieht, dass bei Erzeugung der fnften Zeile alleine binom(2,1) vier mal bentigt wird. Bei Erzeugung der sechsten Zeile wird binom(2,1) zustzlich acht mal aufgerufen, bei Erzeugung der siebenten Zeile zustzlich sechzehn mal und so weiter. Diese hohe Anzahl an rekursiven Aufrufen entsteht, weil der Algorithmus die Ergebnisse nicht zwischenspeichert und stattdessen dieselben Aufrufe immer wieder durchfhrt. In Listing 2.40 wurde das Programm binom aus Listing 2.38 so gendert, dass die Eintrge des Pascalschen Dreiecks in einem mehrdimensionalen Array gespeichert werden. So kann einfach auf bereits berechnete Binomialkoezienten zugegrien werden, ohne sie neu zu berechnen. Der Unterschied in der Ezienz wird deutlich, wenn die Anzahl der auszugebenden Zeilen grer wird. Machen Sie einen Versuch mit 30 Zeilen. Man muss vorsichtig sein, wenn man die Ursachen fr die unterschiedlichen Laufzeiten der beiden Programmversionen vergleicht. Der Laufzeitunterschied wird nicht durch Verwendung von Schleifen statt Rekursion verursacht, sondern durch hug wiederholte Berechnungen in Listing 2.38 und Verwendung von in einem Array zwischengespeicherter Daten in Listing 2.40. In diesem Beispiel lassen sich wiederholte Berechnungen einfacher durch Rekursion als durch Schleifen ausdrcken, und Arrayzugrie
147
2 Grundlegende Sprachkonzepte
Listing 2.40: Berechnung des Pascalschen Dreiecks mit Zwischenspeicherung 1 import [Link]; 2 3 public class Pascal { 4 5 public static void main(String[] args) { 6 7 Scanner sc = new Scanner([Link]); 8 9 int zeilen = [Link](); 10 11 int[][] binom = new int[zeilen+1][]; 12 13 for (int n = 0; n < zeilen; n++) { 14 15 binom[n] = new int[n+1]; 16 //Erstes und letztes Element der Zeile mit 17 //dem Wert 1 belegen: 18 binom[n][0] = 1; 19 binom[n][n] = 1; 20 21 //Alle anderen Elemente als Summe der 22 //entsprechenden Elemente der Vorgngerzeile 23 for (int k = 1; k < n; k++) { 24 25 binom[n][k] = binom[n-1][k-1] + binom[n-1][k]; 26 [Link](binom[n][k] + " "); 27 28 } 29 30 [Link](); 31 } 32 33 } 34 35 }
sind einfacher mittels Schleifen als durch Rekursion zu realisieren. Wie wir in Kapitel 4 sehen werden, sind rekursive und iterative Lsungen immer gegeneinander austauschbar. Sehr oft ist es so wie in diesem Beispiel, dass Laufzeit und Speicherverbrauch gegeneinander austauschbar sind. So manche einfache Mehrfachverzweigung kann durch einen Arrayzugri ersetzt werden. Beispielsweise ersetzt das Codestck in Listing 2.41
148
2.6 Zustnde
Listing 2.41: Array als Ersatz der switch-Anweisung aus Listing 2.24 1 String[] notentext = { "Fehler: keine gltige Note", 2 "sehr gut", 3 "gut", 4 "befriedigend", 5 "gengend", 6 "nicht gengend" 7 }; 8 text = notentext[note < 1 || note > 5 ? 0 : note];
die switch-Anweisung aus Listing 2.23 bzw. 2.24 und reduziert damit die Fehleranflligkeit und verbessert die Wartbarkeit. Bei jedem Arrayzugri muss sichergestellt sein, dass der Index im erlaubten Wertebereich (von 0 bis zur Lnge des Arrays minus 1) liegt. In Listing 2.40 ist dies bereits durch die mglichen Werte der Laufvariablen garantiert. Aber in Listing 2.41 brauchen wir dafr eine Fallunterscheidung, da note auch einen anderen Wert als im Bereich von 1 bis 5 haben knnte.
2.6 Zustnde
Zustnde spielen in imperativen Programmiersprachen eine wichtige Rolle. Jede Variable hat einen Zustand, nmlich ihren Wert. Allgemeiner ausgedruckt hat jedes Objekt, beispielsweise ein Array, einen Zustand, der sich aus der Gesamtheit der Zustnde seiner Elemente ergibt. Der Programmzustand ist ein Schnappschuss aller im Programm denierten Variablen. Er umfasst also die Zustnde aller verwendeten Variablen zu einem bestimmten Zeitpunkt der Ausfhrung. 2.6.1 Seiteneekte Unter einem Seiteneekt versteht man eine nderung des Programmzustands. Die Auswertung eines Ausdrucks hat oft keinen Seiteneekt, da nur ein Wert berechnet wird. Eine Zuweisung dagegen hat einen Seiteneekt, da sie den Programmzustand ndert. Betrachten wir folgende Ausdrcke: (i + 1) + (i + 1) 2 * (i + 1) (i == j) && (i == j)
149
2 Grundlegende Sprachkonzepte
Die Auswertung dieser Ausdrcke liefert je einen Wert, der weiter verwendet werden kann (z.B. auf der rechten Seite einer Zuweisung). Die Auswertung alleine bewirkt keine Zustandsnderung. Diese Ausdrcke haben keine Seiteneekte. Weiters hngen die Werte der Ausdrcke alleine von den Ausdrcken selbst ab, also nur von den in den Ausdrcken genannten Variablen und nicht vom restlichen Programmzustand. Der zweite Ausdruck ist anstelle des ersten verwendbar, beide ergeben an derselben Stelle im Programm denselben Wert. Genauso knnen wir den dritten Ausdruck mit Hilfe der quivalenz (a && a) == a durch i == j ersetzen. Betrachten wir nun folgende Ausdrcke mit Seiteneekten: (i++) + (i++) 2 * (i++) (++i == j) && (++i == j) Auch diese Ausdrcke liefern je einen Wert. Gleichzeitig wird aber bei der Auswertung jeweils der Wert von i verndert. Daher sind die ersten beiden Ausdrcke nicht mehr quivalent zueinander. Weiters liefert im ersten Ausdruck der linke Teilausdruck einen anderen Wert als der rechte. Der dritte Ausdruck ist immer false, weil die Variablen i links und rechts von && unterschiedliche Werte haben. Seiteneekte treten beispielsweise auch bei der Ein- und Ausgabe auf. Beim Einlesen eines Wertes z.B. mittels versuch = [Link](); ist das klar, da der eingelesene Wert in einer Variablen abgelegt wird. Aber auch beim Ausgeben eines Wertes mittels [Link](m) verndern wir auf gewisse Weise den Zustand des Systems. Mit solchen Seiteneekten werden wir uns in Abschnitt 2.7 eingehender beschftigen. Durch Ein- und Ausgaben kann jede Methode in Java Seiteneekte haben. 2.6.2 Funktionen und Prozeduren in Pascal In vielen Programmiersprachen wird klar zwischen Funktionen und Prozeduren unterschieden. In Java werden beide Konstrukte in Methoden zusammengefasst. Um uns die Unterschiede zwischen Funktionen und Prozeduren vor Augen zu fhren, betrachten wir hier die Programmiersprache Pascal. Funktionen hneln mathematischen Funktion. Sie sollten keine Seiteneekte haben. Einer Funktion werden Werte als Argumente bergeben, und sie liefert einen Wert als Ergebnis zurck. Der Funktionsaufruf lsst den Programmzustand unverndert. In Java lassen sich solche reinen Funktionen durch Methoden ohne Seiteneekte implementieren.
150
2.6 Zustnde
Listing 2.42: Funktion in Pascal zum Summieren der Zahlen von m bis n function sum(m:cardinal, n:cardinal): cardinal; var s: cardinal; begin s := 0; while m <= n do begin s := s + m; m := m + 1 end; sum := s end;
Eine Prozedur hat keinen Rckgabewert, aber Seiteneekte. Sie greift meist auf Objekte zu, die auch auerhalb der Prozedur gltig sind. Listing 2.42 zeigt die Denition einer Funktion in der Programmiersprache Pascal. Die Funktion berechnet die Summe der ganzen Zahlen im Intervall m bis n, die als formale Parameter in der Funktionsdeklaration angegeben sind. Der Datentyp cardinal steht in Pascal fr positive ganze Zahlen und wird fr die beiden Parameter nach dem Doppelpunkt innerhalb der runden Klammern angegeben. Der Ergebnistyp der Funktion, der am Ende des Funktionskopfs vor dem Strichpunkt angegeben wird, ist ebenfalls cardinal. Die Funktion benutzt neben den beiden formalen Parametern eine lokale Variable s, ebenfalls vom Typ cardinal. Der eigentliche Anweisungsblock bendet sich zwischen den Schlsselwrtern begin und end (die in Pascal eine hnliche Rolle spielen wie die geschwungenen Klammern in Java). Der Zuweisungsoperator ist :=. Die Anweisung sum := s entspricht der Anweisung return s; in Java. Der Aufruf der Funktion kann beispielsweise folgendermaen aussehen: a := 5; b := 10; c := sum(a,b); Writeln(c); In der ersten Zeile werden die Variablen initialisiert, die als Argumente an die Funktion bergeben werden. In der zweiten Zeile wird das Ergebnis der Funktion der Variable c zugewiesen und in der dritten Zeile ausgegeben.
151
Das Ergebnis hngt nur von den Werten der aktuellen Parameter ab. Beim Aufruf werden die Werte der aktuellen Parameter an die formalen Parameter zugewiesen. Die Funktion arbeitet mit Kopien der vom Aufrufer bergebenen Werte. Werden diese innerhalb der Methode gendert, beeinusst dies den aufrufenden Programmteil nicht. Ein solcher Aufruf wird Call-by-Value (Aufruf mit Wertzuweisung) genannt, da den formalen Parametern ein Wert zugewiesen wurde. Abbildung 2.43 veranschaulicht den Aufruf der Pascal-Funktion sum, wobei Parameterbergaben als gestrichelte Pfeile dargestellt sind. Werte der formalen Parameter ndern sich whrend der Berechnung. Fr den Aufrufer bleiben diese nderungen ohne Auswirkungen. Bei der Rckkehr aus der Funktion wird der Wert 45 geliefert und an c zugewiesen. Listing 2.44 deniert einer Prozedur in Pascal. Sie berechnet so wie die Funktion aus Listing 2.42 die Summe der Zahlen von m bis n, liefert jedoch keinen Ergebniswert. Stattdessen hat sie einen weiteren Parameter. Der dritte Parameter (nach dem Schlsselwort var) spielt eine besondere Rolle: Es ist ein Variablenparameter. Ihm wird nicht der R-Wert des Arguments zugewiesen, sondern der L-Wert, also die Speicheradresse der als Argument bergebenen Variablen. Der formale Parameter enthlt eine Referenz auf die bergebene Variable. Auf diese Weise gibt es nun zwei Namen fr dieselbe Variable, einen innerhalb und einen auerhalb der Pro-
152
2.6 Zustnde
Listing 2.44: Prozedur in Pascal zum Summieren der Zahlen von m bis n procedure begin while begin s m end; end; addsum(m:cardinal; n:cardinal; var s:cardinal); m <= n do := s + m; := m + 1
Aufruf
Rckgabe
zedur. Einen solchen Aufruf, bei dem nicht Werte, sondern Referenzen auf Variablen zugewiesen werden, nennt man Call-by-Reference. Abbildung 2.45 zeigt den Aufruf der Pascal-Prozedur addsum. Whrend m und n Wertparameter sind ist s ein Variablenparameter. Zuweisungen beim Aufruf sind als gestrichelte Pfeile dargestellt. Der durchgehende Pfeil veranschaulicht, dass ber s in der Prozedur dieselbe Variable angesprochen wird wie ber c im Aufrufer. Alle nderungen, die die Prozedur auf der Variablen s durchfhrt, wirken sich unmittelbar auch auf c aus, da s und c verschiedene Namen fr dieselbe Variable sind. Die Prozedur hat eine nachhaltige Wirkung auf die Variablenparameter, auch aus Sicht der Aufrufer. Die Ausgabe des folgenden Programmstcks ist 52:
153
2 Grundlegende Sprachkonzepte
a := 5; b := 10; c := 7; addsum(a,b,c); Writeln(c); Mit Hilfe eines Variablenparameters kann der Aufrufer also sowohl Werte an die Prozedur bergeben als auch Ergebnisse von der Prozedur erhalten. Manchmal wird dafr auch der Begri Durchgangsparameter benutzt. Die Prozedur addsum addiert die Summe der natrlichen Zahlen von a bis b zu c. Prozeduren funktionieren also hnlich wie Zuweisungen oder andere Ausdrcke mit Seiteneekten. 2.6.3 Methoden mit Seiteneekten in Java In Pascal gibt es die Unterscheidung zwischen Wertparametern (Callby-Value) und Variablenparametern (Call-by-Reference). In Java gibt es nur Wertparameter. Bei der Parameterbergabe in Java wird also stets der Wert des Arguments in den formalen Parameter kopiert, niemals die Adresse einer als Argument verwendeten Variablen. Anders als in Pascal unterscheidet man in Java jedoch zwischen elementaren Typen und Referenztypen. Ist das Argument von einem elementaren Typ, wird durch Call-by-Value direkt der Wert des Arguments an den formalen Parameter zugewiesen. Ist das Argument von einem Referenztyp, wird (ebenfalls durch Call-by-Value) eine Referenz auf das Objekt an den formalen Parameter zugewiesen. Es handelt sich auch bei Referenztypen um Call-byValue, weil der Inhalt einer als Argument verwendeten Variablen bergeben wird, nicht deren Adresse. Der Inhalt einer Referenzvariablen ist ja eine Referenz auf ein Objekt, und der formale Parameter enthlt eine Referenz auf ein Objekt, keine Referenz auf eine Variable. Obwohl es in Java kein Call-by-Reference gibt, hneln die Auswirkungen der Parameterbergabe jener von Call-by-Reference, wenn Argument und formaler Parameter von einem Referenztyp sind. Die Verwendung von Referenztypen ermglicht in Java Methoden mit Seiteneekten, die hnlich funktionieren wie Prozeduren in Pascal. Die Methoden int ggt(int,int) aus Listing 2.25 und Listing 2.30 sind Beispiele fr reine Funktionen, die sich beim Aufruf wie Ausdrcke ohne Seiteneekte verhalten. Es knnen zwar whrend der Ausfhrung Zustandsnderungen auftreten, diese beschrnken sich aber auf lokale Variablen, die fr bergeordnete Programmteile (Aufrufer) nicht sichtbar sind. Der Wert, den die Methode liefert, hngt ausschlielich von den Argumenten ab und ist unabhngig vom restlichen Programmzustand zum
154
2.6 Zustnde
Listing 2.46: Methode mit Seiteneekten 1 import [Link]; 2 3 public class SequenceTest { 4 public static void main(String[] args) { 5 int[] ia = {3,2,6,5,4,1}; 6 7 [Link](r(ia)+r(ia)); 8 //4 9 10 [Link](2*r(ia)); 11 //6 12 13 [Link](2*r(ia)); 14 //2 15 16 } 17 18 // r kehrt die Reihenfolge im Array um und 19 // liefert das danach letzte Element zurck 20 public static int r(int[] seq) { 21 22 for (int i = 0; i < [Link]/2; i++) { 23 int swap = seq[[Link] - i - 1]; 24 seq[[Link] - i - 1] = seq[i]; 25 seq[i] = swap; 26 } 27 28 return seq[[Link] - 1]; 29 30 } 31 }
Zeitpunkt des Aufrufs. So kann beispielsweise ggt(i,j) + ggt(i,j) stets durch den einfacheren Ausdruck 2 * ggt(i,j) ersetzt werden. Listing 2.46 zeigt eine Methode r mit Seiteneekten. Im Gegensatz zu ggt manipuliert sie ein Objekt, das fr bergeordnete Programmteile sichtbar ist und auch vor und nach Ausfhrung der Methode existiert. Dies wird dadurch ermglicht, dass der formale Parametertyp ein Referenztyp ist. Das Argument ist also eine Referenz. Wie bei einfachen Werten wird diese Referenz bei der Zuweisung kopiert. Das referenzierte Objekt wird dabei nicht dupliziert.
155
Objekt
Index: 0 Index: 1 Index: 2 Index: 3 Index: 4 Index: 5
Methode: ggt
Methode: r seq
ref
m 4
n 8
Abbildung 2.47 veranschaulicht die Unterschiede zwischen Parametern elementarer Typen (links) und solchen von Referenztypen (rechts). Der links dargestellte Aufruf wird in Listing 2.30 (Zeile 16) durchgefhrt. Der Aufruf rechts wird in Listing 2.46 (z.B. Zeile 7) durchgefhrt. Referenzen sind als durchgezogene Pfeile dargestellt, implizite Zuweisungen beim Methodenaufruf als gestrichelte Pfeile. Parameter mit Referenztypen ermglichen Seiteneekte, obwohl der Parameterbergabemechanismus keinen Unterschied zwischen elementaren Typen und Referenztypen macht. Referenzen auf dasselbe Objekt knnen auch in anderen Programmteilen existieren, und das Objekt ist von all diesen Programmteilen aus zugreifbar. Verndert eine Ausfhrung einer Methode wie r das Objekt, so sind die nderungen auch in allen anderen Programmteilen sichtbar, die ber eine Referenz auf das Objekt verfgen. Wrde man innerhalb der Methode dem formalen Parameter einen anderen Wert zuweisen, so bliebe diese nderung dem Aufrufer jedoch verborgen. Trotz der Verwendung von Referenzen handelt es sich nicht um Call-by-Reference. Jede Vernderung des Objekts bleibt nach Verlassen der Methode erhalten, da direkt mit dem Objekt, nicht mit einer Kopie gearbeitet wird. Der Vorteil besteht darin, dass auf diese Weise Information sehr einfach von einem Programmteil zu den anderen weitergegeben wird eine einfache und direkte Form der Kommunikation. Dies ist jedoch gleichzeitig ein Nachteil: Es passiert leicht, dass man durch nderungen von Objekten ungewollt Berechnungen in anderen Programmteilen beeinusst. Man muss daher beim Programmieren groe Vorsicht walten lassen.
156
2.6 Zustnde
2.6.4 Funktionaler und prozeduraler Programmierstil Wie bereits im Abschnitt 1.5.2 erwhnt, lsst sich jeder Algorithmus, je nach Programmierstil, als Verschachtelung von Ausdrcken (rein funktionaler Stil) oder als Folge von Operationen mit Seiteneekt (prozeduraler Stil) ausdrcken, wobei Prozeduraufrufe so wie Zuweisungen als Operationen mit Seiteneekt gesehen werden knnen. In der funktionalen Programmierung hat die Auswertung von Ausdrcken und Funktionen keine Zustandsnderungen zur Folge. Es gibt in rein funktionalen Sprachen keine Anweisungen im Sinn der imperativen Programmierung, die eine Zustandsnderung bewirken, sondern nur Ausdrcke ohne Seiteneekte. Obwohl Java keine funktionale Programmiersprache ist, lassen sich Algorithmen auch funktional ausdrcken. Als Beispiel haben wir in Abschnitt 2.4 zwei verschiedene Mglichkeiten gesehen, den grten gemeinsamen Teiler von zwei Werten zu berechnen. Vergleicht man die iterative Methode in Listing 2.25 mit der rekursiven Methode aus Listing 2.30, erkennt man, dass die rekursive Variante im Gegensatz zur iterativen Variante ohne Zuweisungen im Methodenrumpf, also ohne Zustandsnderungen von lokalen Variablen auskommt. Es wird nur deniert, welche aktuellen Parameter die verschachtelten (rekursiven) Funktionsaufrufe haben. Die beim Methodenaufruf angegebenen aktuellen Parameter ndern sich durch die Ausfhrung der Methode nicht. Die Ausfhrung gleicht also eher der Auswertung eines einfachen Ausdrucks. Die Methodendenition folgt einem funktionalen Programmierstil. Die Denition der Methode hnelt der mathematischen rekursiven Denition des GGT. Ganz hnlich verhlt es sich mit der Implementierung der Methode int ggt(int,int,int). Hier wird im Methodenrumpf nur ein verschachtelter Ausdruck ausgewertet. Dieses Beispiel zeigt, wie der funktionale Programmierstil ohne Zustnde und Seiteneekte auskommt. Die iterative Methodendenition betont hingegen die dynamische Abfolge von Schritten zur Berechnung des Resultats. Die Ausfhrung bewirkt Zustandsnderungen von lokalen Variablen. Der rein funktionale Programmierstil hat in vielen Bereichen klare Vorteile: Man braucht Objekte nicht von Kopien dieser Objekte zu unterscheiden, da dieser Unterschied aufgrund fehlender Seiteneekte nirgends sichtbar wird. Diese Eigenschaft heit referentielle Transparenz. Sie erleichtert die Programmierung und verringert die Anflligkeit fr Fehler. Daher knnen erfahrene funktionale Programmierer(innen) Aufgaben sehr rasch und ezient lsen. Erfahrung hinsichtlich der Strukturierung von Programmen
157
2 Grundlegende Sprachkonzepte
ist aber ntig, da einzelne Programmteile nicht ber gemeinsam sichtbare Variablen miteinander kommunizieren knnen. In der prozeduralen Programmierung knnen Programmteile, wie oben beschrieben, sehr leicht ber gemeinsam sichtbare vernderbare Objekte kommunizieren. Das erleichtert die Strukturierung von Programmen, erhht aber die Fehleranflligkeit. Heute gilt die prozedurale Programmierung, so wir sie hier beschrieben haben, eigentlich schon als veraltet. Jedoch wird die objektorientierte Programmierung als Erweiterung der prozeduralen Programmierung sehr hug eingesetzt. Wie wir in Kapitel 3 sehen werden, erlaubt die objektorientierte Programmierung eine bessere Kontrolle des Umgangs mit Zustnden, sodass die fehlende referentielle Transparenz nicht allzusehr ins Gewicht fllt. Mit wenig Programmiererfahrung sieht man die prozedurale Programmierung oft als einfacher als die funktionale Programmierung an, da ein auf Zustandsnderungen aufgebautes Maschinenmodell (angelehnt an die Hardware) viel naheliegender wirkt als das abstraktere funktionale Modell (abgeleitet vom Lambda-Kalkl). Tatschlich ist die prozedurale Programmierung aber nicht einfach. Der Grund dafr sind die Seiteneekte, die sich oft anders auswirken als man denkt. Je umfangreicher und komplexer die Programme werden, umso schwieriger ist es, die Auswirkungen von Seiteneekten zu berblicken.
158
Die Methode muss als entlich sichtbare (public), statische Methode ohne Rckgabe mit der Signatur void main(String[]) deniert werden. Andernfalls wird main nicht als Einstiegspunkt akzeptiert. Die einfachste Form der Eingabe in ein Programm stellen Kommandozeilenargumente dar, das sind Argumente, die einem Programm beim Aufruf (in der Regel von einer sogenannten Kommandozeile oder Konsole aus) mitgegeben werden. Ein Programmaufruf kann beliebig viele, durch Leerzeichen voneinander getrennte Argumente enthalten. Beispielsweise sieht der Aufruf des Java-Programms in der Klasse Euklid (Listing 2.48) mit vier Kommandozeilenargumenten so aus: java Euklid 16 8 128 12 Der formale Parameter args von main (im Beispiel in der Klasse Euklid) enthlt die Kommandozeilenargumente. Dieser Parameter ist ein Array von Zeichenketten vom Typ String[]. Jeder Arrayeintrag entspricht einem Argument. Intern wird also jedes Kommandozeilenargument als Zeichenkette betrachtet, auch wenn es beispielsweise eine Zahl reprsentiert. Wenn wir die Zeichenketten als Werte anderer Typen ansehen wollen, mssen wir sie vor der weiteren Verarbeitung in Werte der gewnschten Typen umwandeln. Beispielsweise wandelt die vordenierte Methode int [Link](String) eine Zeichenkette in eine ganze Zahl um, und double [Link](String) in eine Fliekommazahl. Integer und Double sind hier die Namen der Klassen, in denen die Methoden deniert sind. Generell knnen wir entlich sichtbare statische Methoden, die in anderen Klassen deniert sind, durch Voranstellen des Klassennamens vor den Methodennamen aufrufen, wobei ein Punkt die beiden Namen voneinander trennt. Das Programm in Listing 2.48 akzeptiert beim Programmstart eine beliebig lange Liste von positiven ganzen Zahlen als Kommandozeilenargumente und berechnet den grten gemeinsamen Teiler dieser Zahlen. Falls sich eine der in der Kommandozeile angegebenen Zeichenketten nicht in eine ganze Zahl umwandeln lsst, bricht das Programm mit einem Fehler (einer NumberFormatException) ab. Bei Aufruf des Programms muss mindestens ein Argument angegeben werden, da auf das erste Argument mit Index 0 in Zeile 4 in jedem Fall zugegrien wird. Wurden beim Programmaufruf keine Argumente angegeben, bewirkt die Anweisung in Zeile 4 einen Programmabbruch mit einer Fehlermeldung (ArrayIndexOutOfBoundsException).
159
2 Grundlegende Sprachkonzepte
Listing 2.48: Ausfhrbare Klasse 1 public class Euklid { 2 public static void main(String[] args) { 3 4 int ergebnis = [Link](args[0]); 5 6 for (String input: args) { 7 ergebnis = ggt(ergebnis,[Link](input)); 8 } 9 10 [Link](ergebnis); 11 12 } 13 14 public static int ggt(int m, int n) { 15 16 return m == n ? m : m > n ? ggt(m - n, n) : ggt(m, n - m); 17 18 } 19 }
Das Programm in Listing 2.48 verzichtet auf berprfungen der Eingabe. In der Praxis muss man Benutzereingaben immer berprfen siehe Abschnitt 5.6.1. Fr dieses Beispiel msste man sicherstellen, dass args nicht leer ist und alle Eintrge zu ganzen Zahlen grer als Null konvertiert werden knnen. Wre eine Bedingungen nicht erfllt, mssten die Benutzer(innen) darber informiert werden, und der Rest des Programms drfte nicht mehr ausgefhrt werden. Die Methode main liefert keinen Rckgabewert. Ergebnisse von Berechnungen knnen daher nur durch Aufruf spezieller Ausgabemethoden (z.B. println) an die Auenwelt bergeben werden. In Listing 2.48 wird diese Aufgabe von einem einzigen Aufruf einer Ausgabemethode am Ende von main erledigt. 2.7.2 Einlesen und Ausgeben So wie Eingaben erfolgen auch Ausgaben ber Zeichenketten. Soll ein Wert eines anderen Typs als einer Zeichenkette ausgegeben werden, muss der Wert zuerst in eine Zeichenkette umgewandelt werden. Methoden namens println (meist aufgerufen ber [Link](...)) gibt
160
es daher in vielen Varianten, die fr die entsprechenden Umwandlungen sorgen je eine Variante fr jeden primitiven Typ und eine fr Referenztypen. Alle Varianten schlieen die Ausgabe mit einer Zeilenumschaltung ab, sodass die nchste Ausgabe in einer neuen Zeile erfolgt. Ohne Argument gibt println nur eine Leerzeile aus. Daneben gibt es entsprechende Methoden namens print (meist ber [Link](...) aufgerufen), die so wie println funktionieren, aber keine Zeilenumschaltungen anfgen. Anwendungen von println und print haben wir schon in vielen Beispielprogrammen gesehen. Oft reicht es nicht, wenn wir nur einen einfachen Wert ausgeben. Wir mssen erklrenden Text hinzufgen oder so wie in Listing 2.40 die Ausgabe in mehreren Schritten aufbauen. Dabei ist der Verkettungsoperator + hilfreich. Er sorgt auf einfache Weise fr die ntigen Umwandlungen von Werten aller Typen in Zeichenketten siehe Abschnitt 2.2.2. Die Reihenfolge der Ausgaben entspricht der Reihenfolge, in der die Ausgabemethoden ausgefhrt werden. Dadurch folgen die Ausgaben dem Programmuss. So wie Zuweisungen stellen Ausgaben Seiteneekte dar. Die Eekte bestehen darin, dass etwas an die Auenwelt weitergegeben wird, nicht in den von den Methodenaufrufen zurckgegebenen Werten. Ausgaben in dieser Form sind also nur in einem prozeduralen Programmierstil mglich, nicht in einem funktionalen. Auch Eingaben erfolgen (auer ber Kommandozeilenargumente) durch Aufruf von fr diesen Zweck vorgesehenen Methoden. Dafr haben wir schon Beispiele mittels Scanner gesehen (etwa im Zahlenratespiel und in Listing 2.40): Zuerst wird mittels Scanner sc = new Scanner([Link]); ein Objekt erzeugt, ber das Eingaben erfolgen knnen. Mittels der Aufrufe [Link](), [Link]() und von hnlichen Methoden kann man dann abfragen, ob berhaupt Eingaben oder Eingaben eines gewnschten Typs vorhanden sind. In diesem Fall kann man die nchste Eingabe mittels der entsprechenden Methodenaufrufe [Link](), [Link]() usw. einlesen. Dabei werden Zeichenketten in Werte der entsprechenden Typen umgewandelt. Das alles ist nur mglich, wenn ganz am Anfang unserer Java-Datei der Befehl import [Link]; steht, damit die Klasse Scanner und alle dort denierten Methoden innerhalb unserer Klasse sichtbar gemacht werden.
161
2 Grundlegende Sprachkonzepte
Wie das Ausgeben erfolgt auch das Einlesen in der Reihenfolge, in der die entsprechenden Methoden aufgerufen werden. Auch das Einlesen hat einen Seiteneekt: Im Gegensatz zu einer Ausgabe gibt eine Methode zum Einlesen zwar einen sinnvollen, den gerade eingelesenen Wert zurck, aber nebenbei wird die verfgbare Eingabe verndert. Die Zeichen, die den gerade eingelesenen Wert bilden (einschlielich der Leerzeichen und hnlicher Zeichen, welche die Werte voneinander trennen), verschwinden aus der Eingabe, und die restlichen Zeichen in der Eingabe werden sichtbar. Also auch fr Eingaben bentigt man einen prozeduralen Programmierstil. In der Praxis ist die Behandlung von Eingaben schwieriger als die von Ausgaben. Der Grund besteht darin, dass ein Programm keine Kontrolle darber hat, welche Eingaben gemacht werden. Fast immer mssen wir davon ausgehen, dass Eingaben nicht die gewnschte Form haben, etwa statt ganzer Zahlen andere Texte eingegeben werden. Wir mssen eigene Programmzweige fr den Umgang mit falschen Eingaben vorsehen. Das haben wir beispielsweise im Zahlenratespiel gemacht, wo es einen Programmzweig fr den Fall gibt, dass die Eingabe keine Zahl ist, und einen weiteren Zweig fr den Fall, dass die eingegebene Zahl nicht im erlaubten Wertebereich liegt. Nur in den wenigen Fllen, in denen falsche Eingaben und daraus resultierende falsche Berechnungen und Programmabstrze keinen Schaden verursachen knnen (etwa in Listing 2.40), drfen wir auf derartige berprfungen und eigene Programmzweige verzichten. berprfungen von Eingaben werden wir in Abschnitt 5.6.1 nher behandeln. Eingaben wie bisher verwendet erfolgen nur ber die Standardeingabe ([Link]) und Ausgaben ber die Standardausgabe ([Link]). Normalerweise ist beides mit dem Terminal6 verbunden, ber welches das gerade ausgefhrte Java-Programm gestartet wurde. Das heit, Zeichen, die wir ber die Tastatur in dieses Terminal eintippen, werden am Terminal sichtbar und nach Abschluss einer Zeile (also sobald man Enter bzw. Return gedrckt hat) an die Standardeingabe weitergeleitet, von wo aus sie vom Programm eingelesen werden knnen. Das Eintippen dauert eine gewisse Zeit. Methoden zum Einlesen warten so lange, bis die Zeichen eingetippt sind, auch wenn das sehr lange dauert. Wenn man fertig ist, schliet man die Eingaben ab. Das geschieht zum Beispiel durch die Eingabe von d (also das gleichzeitige Drcken der Tasten Control bzw. Strg. und d). Ausgaben auf die Standardausgabe werden
6
Ein Terminal ist heute meist ein Text-Fenster am graschen Bildschirm, in das Befehle eingegeben werden knnen. Frher hat man darunter eine Kombination aus Textbildschirm und Tastatur verstanden, die den Zugang zu einem Computer ermglicht hat.
162
auf demselben Terminal sichtbar. Es kann durchaus vorkommen, dass eine unvollstndig eingetippte Zeile durch einer Ausgabe unterbrochen wird. Betriebssysteme erlauben es, die Standardein- und/oder -ausgabe umzulenken, beispielsweise von bzw. zu einer Datei. Unter Unix-hnlichen Systemen (etwa Linux) wird durch java Zahlenraten <a >b das Zahlenratespiel so gestartet, dass Eingaben aus einer Datei namens a kommen und Ausgaben in eine Datei namens b geschrieben werden. Man kann die Standardausgabe eines Programms auch mit der Standardeingabe eines anderen Programms verbinden, sodass die vom einen Programm ausgegebenen Daten vom anderen Programm eingelesen werden. Neben den einfachen hier vorgestellten Mglichkeiten zur Ein- und Ausgabe gibt es in Java noch eine Reihe weiterer Mglichkeiten. Einige davon werden wir in den Abschnitten 5.5 und 6.1 kennenlernen. 2.7.3 Transformatorische und reaktive Systeme Durch Programme realisierte Systeme lassen sich nach der Art und Weise, wie sie mit der Auenwelt in Verbindung treten, in transformatorische Systeme und reaktive Systeme unterteilen siehe Abbildung 2.49. Transformatorische Systeme entsprechen den reinen Funktionen wie z.B. int ggt(int,int). Solche Systeme lesen nur zu Beginn Daten ein, berechnen daraus Ergebnisse, und geben diese am Ende aus. Transformatorische Systeme haben keinen inneren Zustand. Die Ergebnisse hngen daher nur von den eingegebenen Daten ab. Transformatorische Programme bzw. Programmteile mssen terminieren, also nach einer gewissen Zeit mit den Berechnungen fertig sein, da sonst keine Ergebnisse geliefert wrden. Abgesehen davon hngen die Ergebnisse nicht von der Laufzeit ab. Im Gegensatz dazu haben reaktive Systeme einen inneren Zustand, der durch Eingaben verndert wird und das Ein- und Ausgabeverhalten mitbestimmt. Reaktive Systeme reagieren auf Ereignisse in der Umgebung, die als Eingaben sichtbar werden. In der Regel haben wir nicht nur eine Ein- und eine Ausgabe, sondern viele aufeinander folgende und teilweise voneinander abhngige oder auch gleichzeitige Eingaben (auch aus unterschiedlichen Quellen, z.B. von Sensoren oder Dateien) und Ausgaben (zu unterschiedlichen Zielen, z.B. Aktoren oder Dateien). Es kommt eine zeitliche Dimension dazu: Ausgaben knnen von der Laufzeit abhngen. Die
163
2 Grundlegende Sprachkonzepte
Eingabe Start Eingabe reaktives System transformatorisches System Ende Ausgabe Laufzeit Ausgabe Ausgabe Umgebung Eingabe
Eingabe
Laufzeit ist potentiell unbegrenzt (Termination ist also fr das Funktionieren des Systems nicht ntig), und das System bendet sich in stndiger Interaktion mit seiner Umgebung. Beispiele fr reaktive Systeme sind Betriebssysteme, Telefonanlagen und Airbag-Steuersysteme. Die meisten greren Programme (in Java genauso wie in anderen Sprachen) entsprechen reaktiven Systemen. So ist das Zahlenratespiel aus Abschnitt 1.1 reaktiv, weil wiederholt Ein- und Ausgaben erfolgen und Eingaben wahrscheinlich von vorangegangenen Ausgaben abhngen. In diesem Beispiel hngt die zu erratende Zufallszahl nicht von Eingaben und der Laufzeit ab, im Allgemeinen knnte das aber durchaus der Fall sein. Obwohl die meisten Programme von der reaktiven Art sind, haben viele Methoden transformatorischen Charakter, stellen also Funktionen dar. Die Methoden bernehmen einzelne, klar umrissene Aufgaben, whrend bergeordnete Programmteile (z.B. die Methode main) die Ein- und Ausgaben koordinieren und andere Methoden zur Durchfhrung der eigentlichen Berechnungen aufrufen. Die meisten Programme nicht nur die in Java sind nach diesem Schema aufgebaut: Es gibt eine reaktive Programmschicht, in der die Kommunikation mit der Auenwelt erfolgt, und eine transformatorische Schicht, in der die eigentlichen Berechnungen unbeeinusst von der Kommunikation erfolgen. Fr die eigentlichen Berechnungen bietet sich ein funktionaler Programmierstil an. Aber fr die reaktive Schicht ist ein prozeduraler Programmierstil notwendig, da Ein- und Ausgaben (auer ganz zu Beginn und am Ende) nur ber Seiteneekte realisierbar ist. Das gilt, im bertragenem
164
Sinn, auch fr rein funktionale Sprachen, in denen Ein- und Ausgaben nur in einer Schicht in der Nhe von main mglich sind. Im nchsten Kapitel werden wir uns mit den Grundlagen der objektorientierten Programmierung beschftigen. Wir werden sehen, dass Objekte auch innerhalb eines Systems miteinander kommunizieren, sich also auch ohne direkte Verbindung zur Auenwelt in der Regel wie reaktive Systeme verhalten. Objekte haben meist interne Zustnde. Dennoch gibt es in guten objektorientierten Programmen auch eine klare Trennung zwischen einer Schicht, in der die Verbindung zur Auenwelt aufgebaut und erhalten wird, und einer Schicht ohne Verbindung nach auen. Beispielsweise bildet im Zahlenratespiel die Klasse Zahlenraten die Verbindung zur Auenwelt, und die Klasse UnbekannteZahl bernimmt die internen Berechnungen, die in diesem Beispiel jedoch uerst trivial sind. Diese sind wie im Beispiel (wo die Ergebnisse vom internen Zustand abhngen) nur selten rein funktional. Um unangenehme berraschungen zu vermeiden achtet man trotzdem stets darauf, dass die Seiteneekte einer Methode so klein und berschaubar wie mglich bleiben und gut dokumentiert sind.
165
2 Grundlegende Sprachkonzepte
Entwicklungsumgebung oder separat einen Editor, Compiler und Interpreter) und spielen Sie mit den einzelnen Sprachelementen von Java. So lernen Sie die Querverbindungen am einfachsten kennen. Natrlich muss man dazu bestimmte Grundkenntnisse haben, aber das in diesem Kapitel vermittelte Wissen sollte dafr ausreichen. Es gibt zwei gegenstzliche Fehler, die in dieser Phase des Programmierenlernens manchmal gemacht werden: Ein (eher seltenerer aber fataler) Fehler besteht darin, dass man zu wenig auf die Details achtet, die in diesem Kapitel vermittelt wurden, und sich stattdessen ganz auf die Programmierbeispiele verlsst. In diesem Fall fehlt gelegentlich das ntige Wissen um den Hintergrund der Beispiele zu verstehen. Es ist also notwendig, sich auch die Details gut anzuschauen. Umgekehrt ist es aber auch falsch, sich zu sehr auf das umfangreiche Detailwissen zu konzentrieren und dadurch die grberen Zusammenhnge aus den Augen zu verlieren. Damit das nicht passiert, sollten Sie die Beispiele selbst ausprobieren, modizieren und kleine Experimente mit ihnen durchfhren. 2.8.1 Kontrollfragen Was ist ein Algorithmus? Wodurch unterscheidet sich ein Kochrezept von einem Algorithmus? Welche hnlichkeiten und Unterschiede bestehen zwischen den Begrien Objekt, Wert und Daten? Welche Mglichkeiten zur Steuerung des Programmusses gibt es? Was ist eine Sequenz, Fallunterscheidung und Iteration? Was ist ein Ausdruck? Was ist eine Variable? Was ist eine Zuweisung? Was ist eine Speicheradresse? Was ist der Unterschied zwischen Lebensdauer, Gltigkeitsbereich und Sichtbarkeitsbereich einer Variablen? Was ist der Unterschied zwischen Deklaration und Denition?
166
Was ist eine Initialisierung? Erfolgt die Initialisierung in Java automatisch? Wo schon, wo nicht? Was sind Typen? Welche elementaren Typen gibt es in Java? Was sind Referenztypen? Was ist ein Operator, was ein Operand? Was heit inx, prx und postx? Was ist ein L-Wert, was ein R-Wert? Wodurch unterscheiden sie sich? Was bewirkt der Modier final auf Variablen? Was ist eine Ausdrucksanweisung? In welcher Reihenfolge werden Ausdrcke in Java ausgewertet? Was ist eine Operatorprioritt und wozu braucht man sie? Was ist die Assoziativitt und wozu braucht man sie fr Operatoren? Welche einstelligen und zweistelligen Operatoren gibt es in Java? Gibt es in Java dreistellige Operatoren? Wie wird bei einer Division gerundet? Was bedeutet Infinity und NaN? Was ist der Restwertoperator? Was ist ein Verkettungsoperator? Wie verhalten sich Zuweisungsoperatoren? Welche relationale Operatoren gibt es? Wie unterscheiden sich logische Operatoren von Bitoperatoren? Was macht der Bedingungsoperator? Was ist eine Typumwandlung?
167
2 Grundlegende Sprachkonzepte
Was passiert bei einer Typumwandlung? Wie unterscheidet sich die einschrnkende von der erweiternden Typumwandlung? Wann erfolgt eine implizite Typumwandlung, wann bentige ich eine explizite Typumwandlung? Was sind konstante Ausdrcke? Was ist ein Literal? Welche Fehler treten bei der Verwendung von Literalen auf? Was ist ein Block? Was ist ein innerer im Vergleich zu einem ueren Block? Darf in einem inneren Block eine Variable mit demselben Namen wie in einem ueren Block verwendet werden? Welche Anweisungen knnen zur Selektion verwendet werden? Erklren Sie die switch-Anweisung! Was ist eine Funktion? Was versteht man unter Unterprogrammen und Routinen? Was ist eine Methode? Wie unterscheiden sich Funktionen, Methoden, Unterprogramme, Routinen und Prozeduren? Was ist der Unterschied zwischen formalen und aktuellen Parametern? Was ist in einem Programm ein Argument? Welche Aufgabe hat die return-Anweisung? Was bedeutet berladen von Methoden? Was geschieht bei einem Methodenaufruf? Was bedeutet Rekursion? Wie unterscheiden sich Rekursion und Iteration voneinander?
168
Was sind Zusicherungen? Welche Arten von Zusicherungen gibt es? Warum verwendet man Zusicherungen? Mit welchen Anweisungen kann eine Iteration implementiert werden? Was ist ein Array? Was sind Indextypen, was sind Elementtypen bei Arrays? Werden Arrays automatisch initialisiert? Was ist eine Initialisierungsliste? Was ist ein mehrdimensionales Array? Wie kann eine for-Schleife speziziert werden? Was ist der Programmzustand? Was ist ein Seiteneekt? Was ist der Unterschied zwischen Funktionen und Prozeduren in der Programmiersprache Pascal? Was ist ein Wertparameter, was ist ein Referenzparameter? Was bedeutet Call-by-Value, was Call-by-Reference, und was davon gibt es in Java? Wie unterscheidet sich Call-by-Reference von der bergabe einer Referenz auf ein Objekt durch Call-by-Value? Was unterscheidet funktionale von prozeduraler Programmierung? Was ist ein transformatorisches, was ein reaktives System? Was ist das besondere an der Methode main? Welche einfachen Mglichkeiten fr die Ein- und Ausgabe haben wir schon kennengelernt?
169
2 Grundlegende Sprachkonzepte
170
3 Objektorientierte Konzepte
Java untersttzt vor allem das objektorientierte Paradigma der Programmierung und erweitert damit das prozedurale Paradigma, mit dem wir uns in Kapitel 2 auseinandergesetzt haben. Der wesentliche und namensgebende Aspekt ist die Untersttzung benutzerdenierter Objekte als wichtigstem Abstraktionsmittel. Groe Programme protieren davon. Zusammen mit inkrementellen und zyklischen Softwareentwicklungsprozessen siehe Abschnitt 1.6.1 hat sich die objektorientierte Programmierung in den letzten Jahren zu dem in der Praxis am hugsten eingesetzten Programmierparadigma entwickelt. Wir wollen uns in diesem Kapitel damit beschftigen, worauf es in der objektorientierten Programmierung ankommt und durch welche Sprachkonzepte Java dazu beitrgt, die objektorientierte Programmierung angenehmer zu gestalten.
171
3 Objektorientierte Konzepte
Das primre Einsatzgebiet der objektorientierten Programmierung ist dagegen die Konstruktion groer Programme mit einem langen Softwarelebenszyklus. Alleine aus diesem Einsatzgebiet knnen wir schon einige Problembereiche und Ziele ableiten: Groe Programme beruhen auf einer Vielzahl an einzelnen Algorithmen, die auf komplexe Weise miteinander verbunden sind. Im Mittelpunkt der objektorientierten Programmierung steht nicht so sehr die Implementierung einzelner Algorithmen, wie wir das von der prozeduralen Programmierung kennen, sondern die Integration vieler Algorithmen zu einer Einheit. Natrlich ist es auch in der objektorientierten Programmierung notwendig, einzelne Algorithmen zu implementieren. Wenn aber die Gre und Komplexitt der Aufgabe steigt, sind wir als Menschen (auch mit sehr viel Programmiererfahrung) nicht mehr in der Lage, die ganze Aufgabe zu berblicken und durch einen einzigen Algorithmus zu lsen. Wir knnen nur einzelne Teilaufgaben unabhngig voneinander durch je einen eigenen Algorithmus lsen. Mit der Summe der Teile ist aber noch lange nicht die ganze Aufgabe gelst. Wir mssen die einzelnen Teile so miteinander verbinden, dass daraus ein in sich konsistentes ganzes Programm entsteht. In der objektorientierten Programmierung brauchen wir Mittel und Wege, um Programmteile weitgehend unabhngig voneinander zu halten und trotzdem miteinander zu verbinden. Aufgrund der groen Komplexitt ist es in der Regel auch nicht mglich, ein groes Programm in einem einzigen Schritt aus vielen einzelnen Teilen zusammenzusetzen. Das entstehende Programm knnten wir nicht durchschauen. Viel eher verwenden wir inkrementelle Softwareentwicklungsprozesse, in denen wir Anfangs nur einen kleinen Teil der Aufgabe lsen und das Programm nacheinander, Schritt fr Schritt, um weitere Teile ergnzen. Dabei nehmen wir in den vorangegangenen Schritten gewonnene Erfahrungen in die nchsten Schritte mit. Die objektorientierte Programmierung hat zum Ziel, Programme im Laufe der Zeit ausbauen zu knnen, ohne bereits bestehende Programmteile stndig umndern zu mssen. Langlebige Software muss ber einen langen Zeitraum gewartet werden. Die Wartung ist sehr aufwendig und kostenintensiv. Hug iet ein Mehrfaches der eigentlichen Entwicklungskosten in die Wartung. Es zahlt sich daher aus, Programme so zu schreiben, dass sie mg-
172
lichst gut wartbar sind. Die einfache Wartung ist ein primres Ziel der objektorientierten Programmierung. Die Struktur eines Programms ist ein entscheidendes Qualittsmerkmal hinsichtlich Einfachheit, Verstndlichkeit und Wartbarkeit. Welche Struktur gut oder weniger gut geeignet ist, hngt von vielen Faktoren ab, nicht zuletzt von der Art des Programms. Die objektorientierte Programmierung bietet eine groe Vielfalt an Mglichkeiten zur Strukturierung und erlaubt uns, mit entsprechender Erfahrung, eine gute Struktur zu whlen. Diese Ziele sind durchwegs recht anspruchsvoll. Auf den ersten Blick scheint es fast unmglich zu sein, dass uns etwas so Einfaches wie Objekte den Zielen nherbringen knnte. Softwareobjekte sind ja nichts anderes als Abbildungen gegenstndlicher oder abstrakter Objekte aus der realen Welt, etwa Alleebume, Kugelschreiber, Notizblcke, Buchstaben und Zahlen. So gut wie alles kann man als Objekt auassen. Die Magie der Objekte kommt hauptschlich daher, dass wir als Menschen es gewohnt sind, mit Objekten umzugehen, Objekte miteinander in Beziehung zu setzen und Interaktionen zwischen Objekten zu verstehen. So ist fr uns ganz selbstverstndlich zu begreifen, was es bedeutet, mit einem Kugelschreiber Buchstaben und Zahlen in einen Notizblock zu schreiben, oder dass der Notizblock aus dem Holz von Bumen, vielleicht auch aus dem eines Alleebaums, erzeugt wurde. Nicht nur Programme, sondern auch die reale Welt, in der wir leben, ist hochgradig komplex. Die Einteilung der Welt in einzelne Objekte ist eine menschliche Vorgehensweise um mit der Komplexitt zurechtzukommen. Das haben wir von klein auf gelernt. Die objektorientierte Programmierung ist also nichts anderes als ein Versuch, diese menschliche Vorgehensweise im Umgang mit Komplexitt auf die Programmierung zu bertragen. Nicht die Programme knnen gut mit Objekten umgehen, sondern wir Menschen knnen einfacher mit Programmen umgehen, die hnlich strukturiert sind wie unsere reale Welt. Nur weil wir in der realen Welt hochgradig komplexe Zusammenhnge zu verstehen gelernt haben, knnen wir das auch in der Software. Kaum jemand von uns wird im Detail wissen, wie aus einem Alleebaum ein Notizblock entsteht oder warum eine auf gewisse Art geschwungene Linie fr einen bestimmten Buchstaben steht. Trotzdem knnen wir den Buchstaben im Notizblock problemlos lesen. Genausowenig mssen wir in der objektorientierten Programmierung im Detail wissen, welcher Wert zu welchem Zeitpunkt an welche Variable zugewiesen wird, um zu ver-
173
3 Objektorientierte Konzepte
stehen wie die Objekte zusammenhngen und miteinander interagieren. Durch den Umgang mit Objekten abstrahieren wir ber Details. In der objektorientierten Programmierung verwenden wir hnliche Formen der Abstraktion, die wir ganz allgemein im menschlichen Denken einsetzen. In Abschnitt 1.3.3 haben wir Objekte als abstrakte Maschinen betrachtet. Das hilft uns dabei, ganz unterschiedliche Sichtweisen eines Objekts miteinander zu verknpfen. Einerseits brauchen wir die oben beschriebene Auenansicht eines Objekts, in der wir ber so viele Details wie mglich abstrahieren um die Komplexitt beim Verbinden einzelner Objekte zu einem greren Ganzen in den Gri zu bekommen. Andererseits mssen wir die Maschine aber auch in allen Details implementieren, da eine Abstraktion alleine nicht lauhig ist. Irgendwie wurde unser Notizblock hergestellt und hat sich die Schreibweise fr Buchstaben entwickelt, auch wenn wir darber abstrahieren. Trotzdem gibt es nicht einfach nur zwei Sichtweisen, eine abstrakte Auenansicht und eine detailreiche Innenansicht, sondern ein komplexes Gefge an unterschiedlichen Sichtweisen. Auch direkt an der Herstellung des Notizblocks Beteiligte kennen nur einen winzigen Ausschnitt aus dem Produktionsprozess. Jemand, der den Alleebaum umsgt, hat keine Ahnung davon, dass daraus ein Notizblock entsteht, und jemand, der das Holz weiterverarbeitet, wei nicht, dass es sich um einen Alleebaum handelt. Es grenzt fast an ein Wunder, dass wir ohne zentrale Steuerung einen Notizblock in die Hand bekommen knnen. hnlich ist es in der objektorientierten Programmierung. Viele Objekte, als abstrakte Maschinen betrachtet, arbeiten zusammen, ohne dass eine Maschine Details der anderen kennt. Zusammen lassen die Maschinen Produkte entstehen, oft ganz ohne zentrale Kontrolle des Produktionsprozesses. Aus dem Blickwinkel der Programmierung macht vielleicht gerade dieser Aspekt den entscheidenden Unterschied zwischen einem prozeduralen und objektorientierten Stil aus: In der prozeduralen Programmierung wollen wir uns stets die Kontrolle ber alle Details des gesamten Programmablaufs verschaen. In der objektorientierten Programmierung geben wir dagegen die zentrale Kontrolle auf. Jede abstrakte Maschine (also die Innenansicht eines Objekts) hat zwar volle Kontrolle darber, wie sie mit anderen Objekten umgeht, von denen sie nur die Auenansicht kennt. Die Maschinen kommunizieren miteinander und tauschen dabei Informationen aus. Sie geben aber nur so viel ber sich preis, wie fr eine produktive Zusammenarbeit notwendig ist. Bei der Programmkonstruktion entwickeln wir eine abstrakte Maschine nach der anderen, jede mglichst unabhngig von den anderen. Neue Ma-
174
schinen knnen jederzeit hinzukommen und mit Kommunikationskanlen bereits bestehender Maschinen verbunden werden. Interne Details einer Maschine sind auch im Nachhinein leicht nderbar, weil andere Maschinen ja nichts darber wissen. Das erleichtert die Wartung, da nderungen lokal bleiben. Abstrakte Maschinen sind auf vielfltige Weise miteinander kombinierbar. So knnen wir problemlos die Struktur unseres Programms schaen, die wir uns wnschen. 3.1.2 Faktorisierung: Prozeduren und Objekte Unter Faktorisierung versteht man die Aufteilung groer Programme in kleine Einheiten, in denen zusammengehrige Eigenschaften und Aspekte des Programms zusammengefasst sind. Der Begri geht aus der Analogie zur Mathematik hervor, wo man unter Faktorisierung das Zerlegen eines mathematischen Objekts (beispielsweise eines algebraischen Terms) in seine Faktoren (Ausklammerung einfacherer Terme) versteht. In der Programmierung ist der Zweck der Faktorisierung hnlich: Wenn zum Beispiel mehrere Stellen in einem Programm aus denselben Sequenzen von Befehlen bestehen, soll man diese Stellen durch Aufrufe einer Methode ersetzen, die genau diese Befehle ausfhrt. Gute Faktorisierung fhrt dazu, dass zur nderung aller dieser Stellen auf die gleiche Art und Weise eine einzige nderung der Methode ausreicht. Bei schlechter Faktorisierung htten alle Programmstellen gefunden und einzeln gendert werden mssen um denselben Eekt zu erreichen. Gute Faktorisierung verbessert auch die Lesbarkeit des Programms, beispielsweise dadurch, dass die Methode einen Namen bekommt, der ihre Bedeutung widerspiegelt. Auch das Programm aus Listing 2.30 zur rekursiven Berechnung des GGT besteht aus kleineren Einheiten. So gibt es beispielsweise die Methode ggt. Diese ist als eigene Einheit fr die Berechnungen der mathematischen Funktion zustndig und hat eine entsprechende Bezeichnung. Sie arbeitet als reine Funktion unabhngig vom Anweisungsblock von main. Daher wre es leicht mglich, den Algorithmus in ihrer Methodendenition durch einen anderen GGT-Algorithmus zu ersetzen, ohne dass im restlichen Programm etwas gendert werden msste. Prozeduren haben, wenn sie ausgefhrt werden, einen eigenen Speicherbereich, der solange existiert, bis die Ausfhrung der Prozedur beendet ist. Danach wird der von den lokalen Variablen belegte Speicher der Prozedur freigegeben. Der Programmzustand ist dennoch global (das heit, ber alle Variablen des Programms) zu bewerten, da eine Prozedur auch auf Va-
175
3 Objektorientierte Konzepte
riablen auerhalb ihres Blocks zugreifen kann (Variablenparameter bzw. Parameter mit Referenztyp). Vor allem ist der Programmzustand deswegen global, weil wir bei der prozeduralen Programmierung versuchen, die Kontrolle ber alle Details zu behalten. Wir glauben das Programm nur dann verstehen zu knnen, wenn wir auch alle mglichen Programmzustnde in ihrer Gesamtheit verstehen. Die objektorientierte Programmierung erweitert die prozedurale Programmierung um zustzliche Mglichkeiten zur Faktorisierung. Der Begri des Objekts rckt in den Mittelpunkt. Einige spezielle, nur auf eingeschrnkte Weise verwendbare Arten von Objekten haben wir in Kapitel 2 bereits kennen gelernt. Eine davon sind Zeichenketten vom Typ String, die beliebig viele Zeichen aneinanderreihen und hnlich den Werten elementarer Typen auf funktionale Weise (ohne Seiteneekte) verwendbar sind. Eine andere Art sind Arrays, also Objekte, die eine bestimmte Anzahl an Elementen desselben Typs enthalten. Hierin unterscheiden sie sich von elementaren Werten. Whrend Arrays aber als Elemente nur Instanzen eines bestimmten Typs enthalten, knnen Objekte allgemein auch Instanzen unterschiedlicher Typen gemeinsam enthalten. Zum Ablegen der Daten verfgt das Objekt ber Objektvariablen, die manchmal auch Instanzvariablen, Datenfelder oder (wenn der Kontext klar ist) kurz Variablen genannt werden. Eine Objektvariable ist einfach nur eine Variable, die in einem Objekt angelegt ist. Ein Objekt kann beliebig viele Objektvariablen beliebiger Typen enthalten. Neben Variablen enthlt ein Objekt auch Methoden. Die Variablen und Methoden in einem Objekt sind untrennbar miteinander verbunden: Whrend die Methoden die Daten in den Objektvariablen zur Erfllung ihrer Aufgaben bentigen, ist die genaue Bedeutung dieser Daten oft nur den Methoden des Objekts bekannt. Ohne die Methoden sind die Daten sinnlos. Die Methoden und Daten stehen zueinander in einer engen logischen Beziehung. Wegen dieser engen Beziehung wird ein Objekt oft als Kapsel verstanden, die zusammengehrende Variablen und Methoden zusammenhlt. Diese Kapsel bildet eine untrennbare Einheit in der Software. Das Zusammenfgen von Daten und Methoden zu einer Einheit nennt man daher Kapselung (Encapsulation). Abbildung 3.1 veranschaulicht dies: Links ist ein Array skizziert, dessen Elemente von auen ber ihren Index direkt zugreifbar sind. Rechts ist ein Objekt als Kapsel mit Objektvariablen (versteckt im Inneren) und Methoden (von auen sichtbar rundherum angeordnet) dargestellt.
176
Methode
Abbildung 3.1: Array versus Objekt als Kapsel von Methoden und Variablen
Whrend die Faktorisierung in der prozeduralen Programmierung ausschlielich ber Methoden erfolgt, stehen zur Faktorisierung in der objektorientierten Programmierung auch und vorwiegend die Objekte zur Verfgung, welche Daten und Methoden zu Einheiten zusammenfassen. In der objektorientierten Programmierung bestehen Programme zur Laufzeit aus mehreren bis vielen Objekten. Diese kommunizieren miteinander, indem sie Methoden aufrufen, die zu anderen Objekten gehren. Bei Ausfhrung der Methoden wird in der Regel auf Objektvariablen zugegrien. Aber blicherweise greift man nicht direkt auf Variablen eines anderen Objekts zu, da man die genaue Bedeutung dieser Variablen im Gegensatz zu den Methoden nicht kennt. Die Faktorisierungsmglichkeiten in der objektorientierten Programmierung sind uerst vielfltig. Aber bei Weitem nicht jede Faktorisierung ist gut. Wenn man beispielsweise Methoden, die eng zusammenhngen und einander hug aufrufen, auf zwei verschiedene Objekte verteilt, dann ist auch der Algorithmus, der hinter diesen Methoden steckt, auseinandergerissen. In diesem Fall ist es schwieriger, nderungen am Algorithmus vorzunehmen. Wenn man umgekehrt Methoden, hinter denen zwei relativ unabhngige Algorithmen stecken, den Algorithmen entsprechend auf zwei Objekte aufteilt, ist es leichter, die Algorithmen unabhngig voneinander zu ndern. Die Qualitt der Faktorisierung liegt in unserer Verantwortung. 3.1.3 Datenabstraktion In Abbildung 3.1 sind die Methoden um die Objektvariablen herum angeordnet. Dies soll andeuten, dass die Auenansicht eines Objekts meist von den Methoden und nicht den Variablen gebildet wird. Whrend man
Methode
177
3 Objektorientierte Konzepte
auf die Elemente eines Arrays direkt von auen zugreift, will man auf die Variablen eines Objekts von auen in der Regel nicht direkt zugreifen knnen. Arrays sind also keine typischen Objekte. Man soll auf das Objekt nur zugreifen, indem man ihm Nachrichten schickt. Eine Nachricht ist eine Auorderung an das Objekt zur Ausfhrung einer seiner Methoden und enthlt die Information darber, welche Methode mit welchen aktuellen Parametern ausgefhrt werden soll. Eigentlich ist das Schicken einer Nachricht nichts anderes als der Aufruf einer Methode in einem Objekt. In der objektorientierten Programmierung verwendet man (im Vergleich zur prozeduralen Programmierung) eine etwas andere Terminologie um klar zu machen, dass wir es mit voneinander weitgehend unabhngigen Objekten zu tun haben, die miteinander kommunizieren. Auch wenn hinter Methodenaufrufen und dem Nachrichtensenden annhernd dieselben Mechanismen stecken, so ist die pragmatische Verwendung doch genauso unterschiedlich wie die Terminologie. Man denkt beim Nachrichtensenden in erster Linie nicht daran, dass dadurch ein bestimmter Teil eines wohldenierten Algorithmus ausgefhrt wird, sondern hat nur eine grobe Vorstellung davon, was die Methode bewirkt. Wie wir in Abschnitt 3.3 noch sehen werden, kennen wir die Methode, die durch die Nachricht zur Ausfhrung kommt, im Allgemeinen gar nicht im Detail. An dieser Stelle wre ein konkretes Java-Beispiel angebracht. Damit haben wir aber ein Problem: In Java lassen sich Objekte nicht direkt ausdrcken, sondern sie werden erst zur Laufzeit als Instanzen von Klassen erzeugt siehe Abschnitt 3.2. Daher mssen wir uns hier noch mit abstrakten Beispielen begngen. Nehmen wir an, wir haben ein Objekt, das einen farbigen Punkt in einem zweidimensionalen Koordinatensystem darstellt. Eine Referenz auf dieses Objekt liegt in einer Variablen namens punkt. Durch [Link](1.2,-1.0) schicken wir diesem Objekt die Nachricht verschiebe(1.2,-1.0). Der Punkt wird darauf reagieren, indem er die Methode void verschiebe(double,double) ausfhrt und (vermutlich) seine Position um 1,2 Einheiten nach rechts und eine Einheit nach unten verschiebt. Wenn die Methode ein Ergebnis zurckgibt, dann liefert eine Nachricht auch eine Antwort zurck. So schicken wir durch [Link]() dem Objekt die Nachricht farbe(), und eine Ausfhrung der Methode String farbe() im Objekt wird als Ergebnis bzw. Antwort eine Zeichenkette (vielleicht "rot") zurckgeben. In welcher Form das Objekt die Koordinaten speichert und wie die Farbe ermittelt wird, wissen wir nicht. Solche Details bleiben uns verborgen, weil wir das Objekt nur von auen betrachten. Aufgrund der Einfachheit
178
des Beispiels knnen wir uns aber vorstellen, wie das Objekt von innen betrachtet aussehen knnte. Diese Vorstellung kann aber tuschen. Mglicherweise sind die Koordinaten intern als Polarkoordinaten abgelegt und nicht als kartesische Koordinaten wir wissen es nicht. Es ist auch problemlos mglich, eine Darstellungsform durch eine andere zu ersetzen. Wir mssen nur wissen, dass die Parameter von verschiebe im kartesischen Koordinatensystem dargestellt sind. Oft ist ein Groteil des Inhalts eines Objekts nach auen nicht sichtbar, und wir knnen uns das interne Aussehen auch nur schwer vorstellen. Beispielsweise ist es beim Lenken eines Fahrzeugs fr den Fahrer nicht ntig zu wissen, welche Gelenke und Gestngeteile dabei wie bewegt werden, solange das Bewegen des Lenkrads die erwartete Wirkung zeigt. Die Auenansicht eines Objekts, das die Lenkung darstellt, entspricht nur dem Lenkrad. Wie die Bedienung eines Lenkrads erlernt und gebt werden muss, so ist auch die Verwendung eines Objekts nicht selbstverstndlich, auch wenn die interne Funktionsweise der Lenkung dafr nicht bekannt zu sein braucht. Gnzlich andere Fhigkeiten braucht man zur Konstruktion einer Lenkung. Es ist dazu gar nicht ntig, ein Fahrzeug selbst lenken zu knnen, solange man den Benutzer(inne)n ein funktionsfhiges Lenkrad zur Verfgung stellen kann. Genauso unterschiedlich von der Auenansicht wirkt die Innenansicht eines Objekts. Man braucht dem Benutzer nur die zur Verwendung des Objekts ntigen Methoden bereitzustellen. Ein Objekt wird hug als Black-Box (den deutschen Begri schwarze Schachtel verwendet man kaum) verstanden. Das bedeutet, man sieht von auen nur die Form der Schachtel, aber nicht deren Inhalt ein idealisiertes Bild zur Veranschaulichung des Unterschiedes zwischen Innen- und Auenansicht eines Objekts. Ganz so idealisiert ist die Unterscheidung in der Praxis doch wieder nicht. Manchmal bezeichnet man ein Objekt als Grey-Box, also als Schachtel, die Teile des Inhalts bei genauem Hinsehen erkennen lsst. Das kann man eventuell mit einem Smartphone vergleichen. Meist wei man nur, wie man damit telefoniert oder andere Dinge macht, die in der Bedienungsanleitung klar beschrieben sind. Aber machmal werden auch Details der internen Implementierung erkennbar, wenn man sich die Mhe macht sich anzusehen, wie man damit eigene Applikationen schreibt, oder gelegentlich auch durch Softwarefehler. Die hier beschriebenen Eigenschaften von Objekten werden unter dem Begri Datenabstraktion zusammengefasst. Es geht im Wesentlichen um zwei Aspekte Kapselung und Data-Hiding die nur zusammen ihre Vorteile ausspielen knnen. Unter Data-Hiding versteht man das Ver-
179
3 Objektorientierte Konzepte
Blick ins Innere
verschieben(double,double)
distanzZumUrsprung() distanzZumUrsprung()
Abstrakte Auensicht
verschieben(double,double)
farbe()
1.2
2.0
1.2
2.0
"rot"
position()
s di
ta
Z nz
um
Ur
sp
ru
g?
"rot"
farbe()
fa
rb
2.
33
38
e?
position()
"ro t"
stecken interner Details bzw. das Verhindern direkter Zugrien auf diese Details von auen. Zur Umsetzung dieser Prinzipien unterscheidet man die Schnittstellen eines Objekts von der Implementierung. Die Implementierung entspricht der Innenansicht, jede Schnittstelle einer Auenansicht. Wie der Plural erkennen lsst, kann jedes Objekt auch mehrere, unterschiedliche Schnittstellen haben, wie wir in Abschnitt 3.3 sehen werden. Eine Schnittstelle legt fest, welche Nachrichten ein Objekt versteht und (in groben Zgen und auf abstrakte Weise) wie das Objekt auf eine Nachricht reagiert. Beispielsweise legt eine Schnittstelle fest, dass ein PunktObjekt die Nachrichten verschiebe (mit passenden Argumenten) und farbe versteht und darauf durch Verschieben des Punktes um die angegebenen Einheiten in kartesischen Koordinaten bzw. Rckgabe der Farbe des Punktes als Zeichenkette reagiert. Eine andere Schnittstelle knnte nur die Nachricht verschiebe (mit einer entsprechenden Beschreibung) festlegen. Verschiedene Schnittstellen erlauben die Verwendung desselben Objekts zu unterschiedlichen Zwecken. Die Implementierung des Objekts legt das in den Schnittstellen unvollstndig beschriebene Verhalten im Detail fest. Die Beschreibung der internen Darstellung der Koordinaten in einem Punkt-Objekt gehrt zur Implementierung. Abbildung 3.2 veranschaulicht den Unterschied zwischen Innen- und Auenansicht. Links ist die innere Struktur eines Objekts als Kapsel mit
180
position?
"1.2; 2.0"
Objektvariablen und Methoden dargestellt. Methodendeklarationen sind in hellgrau und deren Implementierungen in dunkelgrau gehalten. Rechts ist die Auenansicht schematisch dargestellt. Daten und Implementierungen der Methoden gehren nicht zur Auensicht und sind im Inneren verborgen. Der interne Zustand, der durch die Werte in den Objektvariablen bestimmt wird, ist nicht sichtbar. Nur die Schnittstelle des Objekts, die aus den Methodendeklarationen und deren abstrakten Beschreibungen gebildet wird, ist von auen sichtbar. Das Objekt reagiert auf Nachrichten (als Pfeile dargestellt) mit entsprechenden Antworten, die vom Zustand des Objekts abhngen knnen.
Auf Grundlage dieser Klassendenition knnen neue Punkte erzeugt werden. In folgenden Programmzeilen wird die Klasse Punkt benutzt: Punkt p; p = new Punkt(); // Punkt ist Referenztyp // neue Instanz von Punkt
181
3 Objektorientierte Konzepte
Objekt p
ref
x
0.0
y
0.0
Abbildung 3.3: Variable p enthlt Referenz auf Objekt vom Typ Punkt
Jede Klasse entspricht einem Referenztyp. Die erste Zeile deklariert daher eine Variable p vom Typ Punkt, die eine Referenz auf ein Objekt vom Typ Punkt enthalten kann. In der zweiten Zeile wird vom einstelligen Prx-Operator new ein Objekt vom Typ Punkt erzeugt. Der Operand dieses Operators ist der Name der Klasse des zu erzeugenden Objekts. Auf die Bedeutung der runden Klammern werden wir in Abschnitt 3.2.4 eingehen. Der Rckgabewert des Ausdrucks auf der rechten Seite des Zuweisungsoperators ist eine Referenz auf das neu erzeugte Objekt. Sie wird der Variablen p zugewiesen siehe Abbildung 3.3. In einem Programm knnen beliebig viele Objekte derselben Klasse erzeugt werden. Der Ausdruck new Punkt() liefert bei jeder Auswertung ein neues Objekt der Klasse Punkt. Man nennt die Objekte einer Klasse auch Instanzen einer Klasse, genauso wie man von den Instanzen eines Typs spricht. Gelegentlich spricht man auch von Exemplaren einer Klasse. Hug verwendet man einfach nur den Namen der Klasse oder des Typs zur Bezeichnung von Objekten. Beispielsweise spricht man von einem Punkt oder von Punkten, wenn man eine Instanz bzw. mehrere Instanzen von Punkt meint. Hinsichtlich der Terminologie ist Vorsicht angebracht: Mit einem (bestimmten oder unbestimmten) Artikel vor einem Begri oder wenn er im Plural steht, bezieht sich der Begri auf ein Objekt oder mehrere Objekte, ohne Artikel davor und in Einzahl dagegen auf einen Typ oder eine Klasse. Sobald man sich daran gewhnt hat, wirkt diese Sprechweise ganz selbstverstndlich und natrlich. Prinzipiell ist ein Zugri auf die Inhalte eines Objekts mit dem Punktoperator . mglich. Der linke Operand ist eine Referenz auf das Objekt, und der rechte ist der Name des angesprochenen Objektinhalts. So kann man mit p.x und p.y auf die Objektvariablen des Punktes in der Variablen p zugreifen. Listing 3.4 zeigt, wie man damit von auen die Objektvariablen setzen und lesen kann.
182
Listing 3.4: Negativbeispiel: Punkt und PunktTester mit Direktzugrien class Punkt { double x; double y; } class PunktTester { public static void main(String[] args) { Punkt p = new Punkt(); //erster Punkt p.x = 1.0; p.y = 1.5; Punkt q = new Punkt(); //zweiter Punkt q.x = 2.1; q.y = -1.2; [Link]("Punkt 1: (" + p.x + "," p.y + ")"); [Link]("Punkt 2: (" + q.x + "," q.y + ")"); } }
In Listing 3.4 wie in vielen weiteren Listings werden mehrere Klassen deniert. Tatschlich sollte jede Klasse in einer eigenen Datei stehen, die (abgesehen von der Endung .java) so heit wie die Klasse. Mehrere Klassen in einem Listing erhhen die bersichtlichkeit kleiner Beispielprogramme. Aber in der Praxis mit wesentlich greren durchschnittlichen Klassengren sind mehrere Klassen pro Datei gnzlich ungeeignet. Direkte Zugrie auf Objektvariablen sind unerwnscht. Im Sinne der Datenabstraktion sollten solche Zugrie unterbunden und stattdessen geeignete Schnittstellen deniert werden. So wie Punkt derzeit deniert ist, kommen wir nicht ohne direkte Zugrie aus. Obwohl der Programmcode in Listing 3.4 in dem Sinne richtig ist, dass er vom Compiler bersetzt werden kann und zur Laufzeit das macht, was wir von ihm erwarten, ist er dennoch ein Beispiel dafr, wie man es nicht machen soll. Durch direkte Zugrie wird die Erweiterung und Wartung des Programms erschwert. Bei diesem kleinen Beispiel wird uns das gar nicht auallen, aber bei groen Programmen fhrt so etwas langfristig zu gewaltigen Problemen. Wie wir in Abschnitt 3.1 gesehen haben, besteht der eigentliche Zweck eines Objekts darin, Daten und Methoden zu einer Einheit zusammenzu-
183
3 Objektorientierte Konzepte
Listing 3.5: Kapselung: Objektvariablen und Methoden bilden eine Einheit 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class Punkt { double x = 0.0; double y = 0.0; void verschiebe(double deltaX, double deltaY) { x = x + deltaX; y = y + deltaY; } double distanzZumUrsprung() { return [Link](x*x + y*y); } } class PunktTester { public static void main(String[] args) { Punkt p = new Punkt(); [Link](1.0,1.5); [Link](1.0,-1.5); [Link]([Link]()); } }
fassen Prinzip der Kapselung. Das machen wir in Listing 3.5. Zustzlich zu den Objektvariablen enthalten Punkte zwei Methoden. Damit ist jedes Objekt der Klasse Punkt nun selbst in der Lage, seine Position zu verndern (Methode: verschiebe) und seine Distanz zum Ursprung des Koordinatensystems zu ermitteln (distanzZumUrsprung). Mit Hilfe dieser beiden Methoden ist es nicht mehr notwendig, von auen direkt auf die Objektvariablen zuzugreifen. Beim Erzeugen eines neuen Punktes in Zeile 17 werden die Variablen x und y des neuen Objektes mit 0.0 initialisiert, wie in den Deklarationen der Variablen in den Zeilen 2 und 3 ersichtlich ist. Der Aufruf in Zeile 18 bewirkt, dass das Objekt in der Variablen p (das ist das durch den Inhalt von p referenzierte Objekt) die Methode verschiebe mit den aktuellen Parametern 1.0 und 1.5 ausfhrt. Man sagt, die Nachricht verschiebe(1.0,1.5) wird an p geschickt. In den Zeilen 6 und 7 bekommen die Variablen des Objekts durch die Zuweisungen neue Werte. Nach Beendigung der Methodenausfhrung wird mit der Anweisung in
184
Zeile 19 fortgesetzt. Die Variablen x und y existieren auch nach Beendigung der Methode weiter, da sie keine lokalen Variablen sind, sondern Objektvariablen. In Zeile 19 wird die Nachricht verschiebe(1.0,-1.5) an p geschickt und damit erneut die Methode verschiebe desselben Objekts mit anderen aktuellen Parametern aufgerufen. Das fhrt dazu, dass die Zeilen 6 und 7 erneut ausgefhrt werden. Die Variablen x und y enthalten zu diesem Zeitpunkt noch die Werte, die bei der letzten Verschiebung gespeichert wurden, 1.0 und 1.5. Zu diesen Werten werden die Parameter addiert. Danach enthalten x und y die Werte 2.0 und 0.0. Nachdem ein Punkt in der Methode main erzeugt und zweimal verschoben wurde, erzeugt die Anweisung in Zeile 21 die Ausgabe 2.0. Die Variante in Listing 3.5 ist aufgrund von Kapselung deutlich besser als jene in Listing 3.4. Dennoch ist auch diese Variante noch nicht perfekt, da es noch immer mglich wre, von auen direkt auf die Variablen x und y eines Punktes zuzugreifen, obwohl es dafr keinen Grund gibt. Um Datenabstraktion zu erreichen, bentigen wir noch Data-Hiding. Durch gezielte Steuerung der Sichtbarkeit knnen wir Zugrie auf x und y von auen verhindern. 3.2.2 Sichtbarkeit Im Gegensatz zu lokalen Variablen sind Objektvariablen berall im Programm gltig, wo es eine Referenz auf das Objekt gibt. Das heit, wenn p eine Referenz auf ein Objekt vom Typ Punkt ist, dann wissen wir, dass es die Objektvariablen p.x und p.y gibt. Die Lebensdauer der Objektvariablen entspricht der Lebensdauer des Objekts. Der Speicherbereich fr die Objektvariablen wird reserviert, wenn das Objekt ber den new-Operator erzeugt wird. Konzeptionell endet die Lebensdauer erst mit dem Ende der Programmausfhrung. Wir werden jedoch noch sehen, dass der Speicherbereich von Objekten, auf die kein Zugri mehr mglich ist, auch schon frher vom System automatisch freigegeben werden kann. Der Gltigkeitsbereich lokaler Variablen stimmt mit dem Sichtbarkeitsbereich berein. Dagegen kann der Sichtbarkeitsbereich von Objektvariablen gegenber dem Gltigkeitsbereich eingeschrnkt werden. Beispielsweise sind die beiden Objektvariablen x und y in Listing 3.6 mit dem Modier private deklariert, der dafr sorgt, dass die Variablen auerhalb der Klasse Punkt nicht sichtbar und daher auch nicht zugreifbar sind, obwohl sie gltig sind, also in jeder Instanz von Punkt existieren.
185
3 Objektorientierte Konzepte
Listing 3.6: Datenabstraktion: Trennung der Innen- von der Auenansicht public class Punkt { private double x = 0.0; private double y = 0.0; public void verschiebe(double deltaX, double deltaY) { x = x + deltaX; y = y + deltaY; } public double distanzZumUrsprung() { return [Link](x*x + y*y); } } public class PunktTester { public static void main(String[] args) { Punkt p = new Punkt(); [Link](1.0,1.5); [Link](1.0,-1.5); [Link]([Link]()); } }
Der Modier private kann auf alle Deklarationen bzw. Denitionen innerhalb einer Klasse angewandt werden, insbesondere auch auf Methoden. Er sorgt dafr, dass die durch die Deklarationen und Denitionen eingefhrten Namen nur innerhalb der Klasse sichtbar sind. hnlich wie private kann man auch den Modier public verwenden, der dafr sorgt, dass die durch die Deklarationen und Denitionen eingefhrten Namen berall sichtbar sind, wo sie gltig sind; das ist im Wesentlichen das ganze Programm. Die beiden Methoden verschiebe und distanzZumUrsprung in Listing 3.6 sind daher berall sichtbar, und wir knnen entsprechende Nachrichten von berall aus an jedes Objekt vom Typ Punkt schicken. In Listing 3.6 sind die beiden Klassen als public deniert, obwohl die Denitionen in keiner anderen Klasse stehen. Das ist ein Sonderfall. Derart denierte Klassen sind sowohl als Klassen als auch als Typen berall verwendbar. Im Gegensatz dazu wren als private denierte Klassen nur innerhalb der Datei sichtbar, in der die Denitionen stehen. Daher sind
186
private Klassen nur sinnvoll, wenn sie zusammen mit anderen Klassen in einer Datei deniert sind. Umgekehrt darf es nicht mehrere public Klassen in derselben Datei geben. Neben public und private gibt es noch zwei weitere Sichtbarkeitsvarianten. Die Default- oder Paket-Sichtbarkeit haben wir automatisch immer dann, wenn wir keinen Modier angeben, um die Sichtbarkeit zu regeln. Dabei sind Namen innerhalb des aktuellen Paketes sichtbar, also innerhalb aller Dateien mit Java-Quellcode, die im selben Verzeichnis stehen. Fr kleine Java-Programme kommen wir mit Paket-Sichtbarkeit als Ersatz fr public aus, aber fr grere Programme sowie Klassen, die in mehrere Programme eingebunden werden sollen, ist Paket-Sichtbarkeit eher selten und nur fr spezielle Zwecke sinnvoll. Wir wollen uns von Anfang an einen Programmierstil angewhnen, der auch fr grere Projekte geeignet ist. Daher sollten wir als Programmieranfnger immer einen Modier hinschreiben. Ausgenommen davon sind nur jene Flle, in denen wir unbedingt Paket-Sichtbarkeit brauchen, und in denen wir unsere Entscheidung fr Paket-Sichtbarkeit begrnden knnen. Es sollte nicht passieren, dass wir einfach darauf vergessen, einen Modier hinzuschreiben. Die vierte Sichtbarkeitsvariante verwendet den Modier protected. Diese Namen sind im selben Paket und in allen Klassen sichtbar, die von der Klasse, in der die Denitionen bzw. Deklarationen stehen, abgeleitet sind; damit werden wir uns spter beschftigen. Auch diese Sichtbarkeitsvariante wird nicht hug verwendet. Die Steuerung der Sichtbarkeit verhilft uns zu einer Trennung der Innenvon der Auenansicht. Jedoch ist die Trennung nicht so gestaltet, dass manches nur innerhalb eines Objekts und anderes auch auerhalb sichtbar ist. Vielmehr kommt es auf die Klasse an, in welcher der Code steht, mit dem auf das Objekt zugegrien wird. Um diesen Unterschied klar zu machen, fgen wir folgende Methode zur Klasse Punkt hinzu: public double entfernung(Punkt p) { double dx = x - p.x; double dy = y - p.y; return [Link](dx*dx + dy*dy); } Da entfernung innerhalb von Punkt steht, drfen wir in dieser Methode natrlich auf die Objektvariablen x und y zugreifen. Allerdings greifen wir auch auf die Objektvariablen p.x und p.y zu, die zu einem
187
3 Objektorientierte Konzepte
anderen Objekt gehren. Das ist erlaubt, weil p vom Typ Punkt ist: Innerhalb der Klasse Punkt drfen wir auf alles zugreifen, das in der Klasse Punkt deklariert oder deniert wurde, auch auf private Inhalte. Wenn entfernung nicht innerhalb von Punkt stehen wrde, drften wir weder auf x und y noch auf p.x und p.y zugreifen. Bezglich Sichtbarkeit kommt es also nicht darauf an, zu welchem Objekt etwas gehrt, sondern nur darauf, in welcher Klasse etwas steht. Diese Form der Steuerung der Sichtbarkeit gibt uns mehr Freiheiten und feinere Kontrolle. Allerdings kann diese Lsung gelegentlich zu Verwirrung fhren. Schlielich denken wir meist in Objekten und deren Innen- und Auenansichten. In der Praxis sollen Objektvariablen fast immer als private deklariert sein. Aber auch fr Methoden ist private oft sinnvoll. Ein Beispiel: public double pfadlaenge(Punkt[] ps) { double sum = 0.0; for (int i=1; i < [Link]; i++) sum += ps[i-1].entfernung(ps[i]); return sum; } Die Methode steht in der Klasse Punkt und berechnet die Lnge eines Pfades, der durch alle Punkte im Array ps luft. Dazu verwendet sie die oben eingefhrte Methode entfernung. Wenn wir pfadlaenge auerhalb von Punkt verwenden mssen, kann diese Methode nicht als private deniert sein; daher ist sie public. Wenn wir allerdings entfernung nur fr die Berechnung von Pfadlngen brauchen und auerhalb von Punkt nicht aufrufen, sollten wir fr entfernung den Modier private verwenden, nicht public. Generell sollte so wenig wie mglich nach Auen sichtbar sein. Leute mit wenig Programmiererfahrung meinen oft, dass es besser wre, vieles nach Auen sichtbar zu machen, damit man, falls das im Laufe der Programmentwicklung notwendig werden sollte, problemlos darauf zugreifen kann. Dahinter steckt jedoch ein grundlegend falscher Denkansatz. Die Trennung von Innen und Auen bewirkt vor allem, dass man nach Auen nicht sichtbare Implementierungsdetails einer Klasse jederzeit ndern kann. Wenn man vieles nach Auen sichtbar macht, geht dieser Vorteil verloren. Falls man als private denierte Methoden doch irgendwann auerhalb der Klasse verwenden muss, ist es in der Regel leicht, private durch public zu ersetzen. Dagegen ist es meist mit groem Aufwand verbunden, nach Auen sichtbare Methoden im Nachhinein zu ndern. Man
188
muss lernen, Data-Hiding nicht als Einschrnkung zu betrachten, sondern als groe Chance fr zuknftige nderungen und Erweiterungen. Wenn man Lehrbcher aufschlgt, die in Java oder die objektorientierte Programmierung einfhren, ndet man als Beispiele hug Methoden wie diese (als Teil der Klasse Punkt): public void setX(double newX) { x = newX; } public double getX() { return x; } Es handelt sich um sogenannte Setter- bzw. Getter-Methoden, die nur eine Objektvariable setzen oder abfragen. Durch solche Methoden bekommt man Zugri auf Objektvariablen, obwohl die Variablen selbst als private deklariert sind. Lehrbcher verwenden diese Beispiele schlicht wegen ihrer Einfachheit. In der Praxis sollte man Setter- und Getter-Methoden vermeiden, so gut es mglich ist, da durch sie die Vorteile des Data-Hiding weitgehend verloren gehen. Wie wir in Abschnitt 2.3.1 gesehen haben, verhindert Java, dass an einer Programmstelle mehrere lokale Variablen gleichen Namens gltig sind. Das gilt jedoch nur fr lokale Variablen. Es ist in Java erlaubt, dass eine lokale Variable (oder ein formaler Parameter) denselben Namen wie eine Objektvariable hat. In diesem Fall wird die Objektvariable von der lokalen Variablen verdeckt. Das heit, wenn man auf eine Variable dieses Namens zugreift, erhlt man die lokale Variable, nicht die Objektvariable. Wie wir spter sehen werden, gibt es Mglichkeiten, trotzdem auf die Objektvariable zuzugreifen. Dennoch ist es in Zweifelsfllen ratsam, Objektvariablen und lokale Variablen unterschiedlich zu benennen. 3.2.3 Identitt und Gleichheit Meistens existieren mehrere Objekte einer Klasse gleichzeitig. Durch Ausfhrung folgender Programmzeilen werden zwei Objekte der Klasse Punkt erzeugt und verschoben: Punkt a1 = new Punkt(); Punkt a2 = new Punkt(); [Link](1.0,2.5); [Link](1.0,2.5);
189
3 Objektorientierte Konzepte
Objekt a1
ref
x
1.0
y
2.5
Objekt a2
ref
x
1.0
y
2.5
Objekt b1
ref
x
2.0
y
5.0
b2
ref
Das Ergebnis ist in Abbildung 3.7 veranschaulicht. Es gibt zwei Objekte mit jeweils gleichen Koordinaten-Werten in den Variablen. Wir sagen, die beiden Objekte sind gleich, aber nicht identisch. Dagegen wird durch den Code in folgenden Zeilen nur ein einziges Objekt erzeugt. Jedoch gibt es zwei Variablen, die dieses Objekt referenzieren: Punkt b1 = new Punkt(); Punkt b2 = b1; [Link](1.0,2.5); [Link](1.0,2.5);
190
Abbildung 3.8 zeigt das Resultat dieser Anweisungen. ber beide Variablen sprechen wir dasselbe Objekt an. Daher wird derselbe Punkt zwei mal verschoben. Wir sagen, die beiden durch b1 und b2 referenzierten Objekte sind identisch, oder kurz b1 und b2 sind identisch. Meist sprechen wir im Plural von identischen Objekten, obwohl die Eigenschaft identisch klar ausdrckt, dass wir es nur mit einem Objekt zu tun haben. Die Abbildungen 3.7 und 3.8 veranschaulichen folgende Eigenschaften eines Objekts, die wir schon in Abschnitt 1.3.3 angesprochen haben: Identitt (Identity): Ein Objekt ist durch seine unvernderliche Identitt, die es bei seiner Erzeugung durch den new-Operator bekommt, eindeutig gekennzeichnet. Wenn man beispielsweise new Punkt() zweimal ausfhrt, bekommt man zwei Objekte mit unterschiedlichen Identitten. ber seine Identitt kann man das Objekt ansprechen, ihm also eine Nachricht schicken. Vereinfacht kann man sich die Identitt als die Adresse des Objekts im Speicher vorstellen. Dies ist aber nur eine Vereinfachung, da die Identitt erhalten bleibt, wenn sich die Adresse ndert, zum Beispiel beim Verschieben des Objekts bei der Speicheroptimierung am Rande der Garbage Collection (das ist die Freigabe des Speichers nicht mehr zugreifbarer Objekte). Jedenfalls gilt: Wenn mehrere Referenzen gleichzeitig auf denselben Speicherplatz verweisen, dann sind die referenzierten Objekte identisch. Es handelt sich nur um ein Objekt siehe Abbildung 3.8. Zustand (State): Der Zustand setzt sich aus den Werten der Variablen im Objekt zusammen. Er ist in der Regel durch Zuweisung neuer Werte an die Objektvariablen nderbar. Zwei Objekte sind gleich (equal ) wenn sie denselben Zustand (und dasselbe Verhalten ausgedrckt durch dieselbe Klasse) haben. Objekte knnen auch gleich sein, wenn sie nicht identisch sind siehe Abbildung 3.7; dann sind sie Kopien voneinander. Zustnde gleicher Objekte knnen sich unabhngig voneinander ndern; die Gleichheit geht dadurch verloren. Identitt kann durch Zustandsnderungen nicht verloren gehen. Verhalten (Behavior): Das Verhalten eines Objekts beschreibt, was das Objekt beim Empfang einer Nachricht, also bei Ausfhrung der entsprechenden Methode macht. Das Verhalten hngt ab von der Nachricht, also dem Methodennamen zusammen mit den aktuellen Parametern,
191
3 Objektorientierte Konzepte
der aufgerufenen Methode (genauer: der Implementierung dieser Methode oder einer abstrakten Vorstellung davon) und dem Zustand des Objekts. Zwei Objekte haben dasselbe Verhalten, wenn sie bei gleicher Nachricht und im gleichen Zustand das gleiche machen (und diese Eigenschaft fr alle mglichen Nachrichten und Zustnde gilt). Das Verhalten eines Objekts wird durch die Klasse des Objekts konkret durch die Methodendenitionen in der Klasse festgelegt. Zwei Objekte derselben Klasse haben immer dasselbe Verhalten. Aber auch zwei Objekte unterschiedlicher Klassen knnen dasselbe Verhalten haben. Wie wir noch sehen werden, sind in der objektorientierten Programmierung auch Implikationen des Verhaltens von groer Bedeutung. Dabei verhlt sich ein Objekt so wie ein anderes, solange man sich auf Nachrichten beschrnkt, die das eine Objekt versteht; das andere Objekt knnte zustzliche Nachrichten verstehen. Die beiden relationalen Operatoren == und != vergleichen zwei Werte elementarer Typen auf Gleichheit siehe Abschnitt 2.2.2. Angewandt auf Objekte (also Instanzen von Referenztypen) vergleichen sie die Objekte jedoch auf Identitt, nicht auf Gleichheit. Beispielsweise liefert b1==b2 (Abbildung 3.8) als Ergebnis true, aber a1==a2 (Abbildung 3.7) liefert false. In jeder Klasse, die vom Java-System vorgegeben ist (beispielsweise String), nden wir die Methode equals, die ein Objekt dieser Klasse mit einem anderen Objekt auf Gleichheit vergleicht. Methoden fr Vergleiche auf Gleichheit mssen wir uns in unseren eigenen Klassen selbst denieren siehe Abschnitt 3.4.3. Der Unterschied zwischen Gleichheit und Identitt ist bedeutend, und die Verwechslung dieser beiden Begrie stellt einen hugen Anfngerfehler dar. Betrachten wir dazu ein Beispiel: String s = "Ergebnis = " + (1 + 1); String t = "Ergebnis = 2"; [Link](s == t); // false [Link]([Link](t)); // true Die Zeichenkette in der Variablen s wird zur Laufzeit berechnet, whrend jene in t als Literal vorgegeben ist. Aufgrund ihrer unterschiedlichen Entstehung sind diese beiden Zeichenketten nicht identisch, aber gleich. Folglich liefert s==t als Ergebnis false, aber [Link](t) ergibt true.
192
Listing 3.9: Initialisierung durch Konstruktor public class Punkt { private double x; private double y; public Punkt(double initX, double initY) { x = initX; y = initY; } ... }
Aus Unachtsamkeit oder Unwissenheit schreibt man leicht s==t, obwohl man die Zeichenketten auf Gleichheit vergleichen mchte, und wundert sich dann, warum sie nicht identisch sind. Gerade Zeichenketten sollte man immer mittels equals vergleichen, niemals mittels ==. 3.2.4 Kontext und Initialisierung Der new-Operator erzeugt ein neues Objekt, wobei der Operand die Klasse des zu erzeugenden Objekts ist. Beispielsweise erzeugt new Punkt() ein Objekt der Klasse Punkt. Das ist jedoch nur ein Teil der Wahrheit. Eine wichtige Frage ist die nach der Initialisierung der Objektvariablen. Die Initialisierung direkt bei der Deklaration wie in Listing 3.5 und 3.6 ist nur in sehr einfachen Fllen mglich, da die Klasse des zu erzeugenden Objekts keine Information ber das Umfeld hat, in dem das Objekt verwendet wird. Dass jeder neu erzeugte Punkt auf dem Ursprung des Koordinatensystems liegen soll, ist eine durch nichts begrndbare Annahme. Dieses Problem wird durch Konstruktoren gelst. Listing 3.9 deniert einen Konstruktor, der die Initialisierung mit Werten vornimmt, die als formale Parameter vorliegen. Ein neues Objekt dieser Variante von Punkt erzeugt man durch new Punkt(1.3,2.7), wenn der Punkt anfangs an den Koordinaten 1,3 und 2,7 liegen soll. Der Operand von new gibt also neben dem Typ des Objekts auch aktuelle Parameter an, die an den Konstruktor weitergeleitet werden. Das entspricht dem Aufruf eines Konstruktors hnlich dem Aufruf einer Methode. Anders als Methoden werden Konstruktoren aber nur bei der Objekterzeugung aufgerufen, niemals in
193
3 Objektorientierte Konzepte
einem bereits initialisierten Objekt. Syntaktisch unterscheiden sich Konstruktoren von Methoden dadurch, dass kein Ergebnistyp speziziert ist und ihr Name dem der Klasse entspricht. Im Rumpf eines Konstruktors kann im Wesentlichen dasselbe stehen wie im Rumpf einer Methode. Auch von Klassen ohne Konstruktor knnen Instanzen erzeugt werden. Ist kein Konstruktor deniert, wird automatisch folgender DefaultKonstruktor ohne Parameter angenommen, der nichts macht: public Klassenname() {} Mit Hilfe des Default-Konstruktors ist es in allen Punkt-Varianten, auer jener in Listing 3.9, mglich, durch new Punkt() ein Objekt zu erzeugen. Die Punkt-Variante in Listing 3.9 erlaubt das nicht, weil der einzige verfgbare Konstruktor zwei Parameter hat. Es kann auch mehrere Konstruktoren in einer Klasse geben. Beispielsweise knnten wir zu Listing 3.9 folgende Konstruktoren hinzufgen: public Point() { x = 0.0; y = 0.0; } public Point(Point p) { x = p.x; y = p.y; }
Damit liefert eine Ausfhrung von new Punkt() dasselbe Ergebnis wie eine von new Punkt(0.0,0.0), und new Punkt(p) liefert eine Kopie eines Punktes p. Der Compiler entscheidet anhand der Parameterzahl sowie der Typen der Argumente im Operanden von new, welcher Konstruktor zu verwenden ist. Generell spricht man von berladen, wenn derselbe Name fr Unterschiedliches steht und der Compiler anhand der Argumente eine Auswahl treen muss. Konstruktoren knnen also genauso wie Methoden berladen sein. Die beiden zu Listing 3.9 hinzugefgten Konstruktoren kann man etwas krzer auch so denieren: public Point() { this(0.0,0.0); } public Point(Point p) { this(p.x,p.y); }
In diesen Beispielen hat this eine ganz spezielle Bedeutung: Die Anweisung this(...) ruft einen anderen Konstruktor derselben Klasse auf. So ruft der parameterlose Konstruktor den Konstruktor mit zwei Parametern auf. Fr diesen Aufruf ist spezielle Syntax ntig, da Konstruktoren ja
194
nicht wie normale Methoden aufgerufen werden knnen. Eine Anweisung der Form this(...) darf nur als allererste Anweisung im Rumpf eines Konstruktors verwendet werden, sonst nirgends. Ungefhr dasselbe kann man statt mit this(...) auch durch Aufruf einer (im Idealfall privaten) Methode erreichen, welche die eigentliche Initialisierung vornimmt. Jeder Konstruktor ruft dieselbe Methode auf. Folgendes Beispiel (als Teil von Punkt) zeigt neben dieser Mglichkeit zur Initialisierung auch eine ganz andere Art der Verwendung von this: public Point(double x, double y) { [Link](x,y); } private void init(double x, double y) { this.x = x; this.y = y; } Hier wird this als Pseudovariable verwendet, also so etwas hnliches wie eine Variable, an die man jedoch keinen Wert zuweisen kann. Der Inhalt von this steht fr eine Referenz auf das Objekt, in dem man sich gerade bendet. Im Rumpf des Konstruktors wird die Nachricht init(x,y) an this geschickt, also an das neue Objekt, das gerade initialisiert wird. Statt [Link](x,y) kann man auch kurz init(x,y) schreiben, da eine Nachricht automatisch an das Objekt geschickt wird, in dem man sich gerade bendet, wenn kein anderer Empfnger angegeben ist. Das entspricht einem einfachen Methodenaufruf. Sowohl im Konstruktor als auch in der Methode init haben die formalen Parameter dieselben Namen wie die Objektvariablen von Punkt, das heit, die formalen Parameter verdecken die Objektvariablen1 siehe Abschnitt 3.2.2. Die Namen x und y bezeichnen also die formalen Parameter, nicht die Objektvariablen. In der Methode init verwenden wir this um dennoch auf die Objektvariablen zuzugreifen; this.x und this.y bezeichnet die ber this zugreifbaren Variablen, also die Objektvariablen. Zur Laufzeit ist (mit einer Einschrnkung, die wir bald nher betrachten werden) immer klar, in welchem Objekt man sich gerade bendet und welchen Inhalt this hat. Innerhalb eines Konstruktors ist es das Objekt, das gerade initialisiert wird. Innerhalb einer Methode ist es das Objekt, an
1
Es wird ausdrcklich nicht empfohlen, Objektvariablen durch formale Parameter oder lokale Variablen zu verdecken. In diesem Beispiel machen wir das nur um zu demonstrieren, wie man dennoch auf Objektvariablen zugreifen kann, die auf diese Weise verdeckt sind.
195
3 Objektorientierte Konzepte
welches die Nachricht geschickt wurde, die zur Ausfhrung der Methode gefhrt hat (also der Empfnger der Nachricht). Die Pseudovariable this verwendet man hauptschlich dazu, eine Referenz auf das Objekt, in dem man sich gerade bendet, als aktuellen Parameter zu bergeben. Beispielsweise erzeugt die Anweisung Punkt p = new Punkt(this); (unter Verwendung des oben eingefhrten Konstruktors mit einem Parameter) eine Kopie des Punktes, in dem man sich gerade bendet, und legt die Kopie in der Variablen p ab. In Abschnitt 3.2.2 haben wir als Beispiel die Methode pfadlaenge deniert. Im Gegensatz zu den meisten Beispiel-Methoden in diesem Kapitel greift diese Methode nirgends auf das Objekt zu, in dem sich die Methode bendet. Weder wird auf eine Objektvariable zugegrien noch eine Methode in this aufgerufen. Trotzdem muss diese Methode in der Klasse Punkt deniert sein, da Zugrie auf die (in einer Variante) private Methode entfernung ntig sind. Methoden wie pfadlaenge knnen mit dem Modier static versehen werden um festzulegen, dass this nicht bentigt wird, etwa so: public static double pfadlaenge(Punkt[] ps) { ... } In solchen statischen Methoden darf this nicht verwendet werden. Wir rufen statische Methoden auch nicht auf, indem wir eine Nachricht an ein Objekt der entsprechenden Klasse schicken, sondern indem wir eine Nachricht direkt an die Klasse schicken: double l = [Link](...); Innerhalb der Klasse Punkt knnen wir den Klassennamen weglassen, sodass sich normale Methodenaufrufe ergeben, wie wir sie in Kapitel 2 mehrfach gesehen haben. Statische Methoden dienen der Vereinfachung. Eine huge Verwendung sollte man jedoch vermeiden, da dies auf einen veralteten prozeduralen Programmierstil hindeutet. In einem modernen objektorientierten Programmierstil muss man immer wieder auf this zugreifen, sodass sich statische Methoden nicht dafr eignen.
196
Fr einen wichtigen Spezialfall brauchen wir unbedingt eine statische Methode, nmlich fr main. Ohne diese Methode wre es gar nicht mglich, ein Java-Programm auszufhren. Neben Methoden knnen auch Objektvariablen als statisch deklariert werden. Diese Variablen gehren dann zur Klasse selbst, nicht zu Instanzen der Klasse. Daher nennt man solche Variablen auch Klassenvariablen. Jede Klassenvariable existiert nur einmal pro Klasse, whrend Objektvariablen einmal pro Objekt existieren. Dieser Unterschied macht den Umgang mit Klassenvariablen schwierig und fehleranfllig. Auer fr Spezialflle sollte man Klassenvariablen daher nicht verwenden. 3.2.5 Konstanten Es gibt jedoch eine wichtige Ausnahme von obiger Regel: Klassenvariablen, die als static und final deklariert sind, haben stets denselben Wert, sodass es keine Rolle spielt, ob sie zu einem Objekt oder zur Klasse gehren. Beispielsweise kann man Namen fr konstante Werte auf diese Weise einfhren: public static final double PI = 3.14; Solche Variablen haben whrend des gesamten Programmablaufs immer denselben Wert. Sie sind keine Variablen im eigentlichen Sinne des Wortes: Die Werte variieren nicht. Man spricht eher von Konstanten, zu denen man auch die Literale zhlt. Zur Unterscheidung schreibt man deren Namen meist nur in Grobuchstaben. Von der Terminologie her werden Konstanten deniert, nicht nur deklariert. Die Verwendung von Konstanten ist zur Verbesserung der Lesbarkeit von Programmen empfehlenswert. Whrend man normale Variablen nicht als public deklarieren soll, sind public Konstantendenitionen hug sinnvoll. So wie in unserem Beispiel ist PI auch in der Klasse Math vordeniert, jedoch mit maximaler Genauigkeit. Durch [Link] knnen wir direkt auf den Wert zugreifen. Eine spezielle Form einer Klassendenition ist die Denition eines Aufzhlungstyps (Enum). Solche Typen werden dort eingesetzt, wo eine kleine Menge von konstanten Objekten bentigt wird. Die Klassendenition beginnt nach dem Schlsselwort enum mit einer Auistung konstanter Objekte gefolgt von der restlichen Klassendenition. Der Aufzhltyp Jahreszeiten in Listing 3.10 deniert eine Klasse mit den vier konstanten Instanzen FRUEHLING, SOMMER, HERBST und WINTER.
197
3 Objektorientierte Konzepte
Listing 3.10: Beispiel eines Aufzhltyps public enum Jahreszeiten { FRUEHLING, SOMMER, HERBST, WINTER; public int wert() { return [Link]() + 1; } } public class EnumExample { public static void main(String [] args) { [Link]([Link]("SOMMER")); for (Jahreszeiten j: [Link]()) { [Link](j + " = " + [Link]()); } } }
Die vordenierte Methode values() liefert ein Array aller Objekte eines Aufzhltyps, valuesOf(String) zu einem String das dazugehrende Objekt und ordinal() die Ordnungszahl eines Objekts. Die Objekte sind in der Reihenfolge ihrer Denition mit 0 beginnend fortlaufend durchnummeriert. Dadurch kann einfach ber alle Elemente eines Aufzhltyps iteriert werden. Das Programm erzeugt folgende Ausgabe: SOMMER FRUEHLING = 1 SOMMER = 2 HERBST = 3 WINTER = 4 Da Aufzhltypen nur konstante Objekte enthalten drfen, gibt es einige Einschrnkungen bei der Denition, auf die wir hier nicht nher eingehen. Generell ist eine huge Verwendung von Aufzhlungstypen oder vieler logisch zusammengehriger Konstanten zu vermeiden, da sie in der Regel in Mehrfachverzweigungen eingesetzt werden. Wie wir in Abschnitt 3.3.2 sehen werden, entsprechen solche Mehrfachverzweigungen einem eher veralteten prozeduralen Programmierstil. Es ist vorteilhaft, stattdessen modernere objektorientierte Konzepte wie dynamisches Binden einzusetzen siehe folgenden Abschnitt.
198
Listing 3.11: Interface eines verschiebbaren Objekts public interface Verschiebbar { // verschiebe Objekt um deltaX Einheiten nach rechts // und um deltaY Einheiten nach oben (fr positive Parameter) // bzw. nach links und unten (fr negative Parameter) void verschiebe(double deltaX, double deltaY); }
Listing 3.12: Interface eines Objekts mit Messung der Distanz zum Ursprung public interface Distanzmessung { // gib Distanz zum Ursprung zurck double distanzZumUrsprung(); }
199
3 Objektorientierte Konzepte
Listing 3.13: Implementierung mehrerer Interfaces public class Punkt implements Verschiebbar, Distanzmessung { private double x, y; public Punkt(double initX, initY) { x = initX; y = initY; } public void verschiebe(double deltaX, double deltaY) { x = x + deltaX; y = y + deltaY; } public double distanzZumUrsprung() { return [Link](x*x + y*y); } public double entfernung(Punkt) { double dx = x - p.x; double dy = y - p.y; return [Link](dx*dx + dy*dy); } }
thoden. Zur Beschreibung von Schnittstellen braucht man nicht zu wissen, wie die Methoden genau deniert sind. Vorlug reicht es zu wissen, dass Methoden mit einer bestimmten Signatur vorhanden sind. Alle Methoden in einem Interface werden als berall sichtbar (also public) und nichtstatisch angenommen. Weil das ohnehin klar ist, braucht man das Wort public nicht hinzuschreiben, und static ist natrlich verboten. Es ist schwierig, alleine aus der Signatur einer Methode herauszulesen, wozu diese Methode dient. Daher verwenden wir Kommentare zur Beschreibung. Neben Methodensignaturen drfen in Interfaces auch Konstanten wie PI deniert werden, also Variablen, die sowohl static als auch final (und implizit auch public) sind und bei der Deklaration initialisiert werden. Andere Variablen sind nicht erlaubt. Listing 3.13 zeigt ein Beispiel fr eine Klasse, die diese beiden Interfaces implementiert. Die implementierten Interfaces mssen in der Klassendenition, voneinander durch Beistriche getrennt, nach dem Schlsselwort
200
Listing 3.14: Interface eines Objekts im Koordinatensystem public interface AufFlaeche extends Verschiebbar, Distanzmessung { // berechne Entfernung zum Punkt p double entfernung(Punkt p); }
implements angegeben werden. Der Compiler berprft, ob es zu allen Signaturen in den Interfaces entsprechende Methodendenitionen in der Klasse gibt. Die Klasse Punkt in Listing 3.13 muss aufgrund der Interfaces die Methoden verschiebe und distanzZumUrsprung als public und nicht static denieren. Daneben drfen natrlich auch weitere Methoden und Variablen deniert bzw. deklariert sein. In einem Interface denierte Konstanten brauchen dagegen nicht nocheinmal in der Klasse deniert werden; auf sie kann man ohne Weiteres lesend zugreifen. Das folgende Diagramm veranschaulicht die Beziehungen zwischen den Klassen und Interfaces entsprechend Listing 3.13: Verschiebbar Punkt Alle Methodensignaturen von Punkt lassen sich beispielsweise folgendermaen in ein Interface packen: public interface AufFlaeche { void verschiebe(double dX, double dY); double distanzZumUrsprung(); double entfernung(Punkt p); } Wenn Punkt statt Verschiebbar und Distanzmessung nur das Interface AufFlaeche implementiert, ergibt sich diese einfache Struktur: AufFlaeche Punkt In der Praxis wird man das jedoch nicht auf diese Weise machen, sondern so wie in Listing 3.14. Auf das Schlsselwort extends folgen die Distanzmessung
201
3 Objektorientierte Konzepte
Namen von Interfaces, deren Inhalte zusammen mit dem Inhalt der geschwungenen Klammern das Interface bilden. Genauso wie obige Denition speziziert die Denition in Listing 3.14 drei Methoden. Zustzlich haben wir aber auch mehr Struktur in die Interfaces hineingebracht: Wir wissen, dass AufFlaeche die beiden Interfaces Verschiebbar und Distanzmessung erweitert. Angenommen, Punkt sei statt wie in Listing 3.13 so wie hier implementiert: public class Punkt implements AufFlaeche { ... } Dann implementiert Punkt nicht nur das Interface AufFlaeche, sondern auch die Interfaces Verschiebbar und Distanzmessung. Jedes Objekt vom Typ Punkt hat somit mindestens vier Schnittstellen:2 Drei Schnittstellen werden von den Interfaces gebildet. Die spezischste Schnittstelle entspricht dem entlich sichtbaren Inhalt von Punkt; sie enthlt neben den in AufFlaeche spezizierten Methoden auch den Konstruktor. Folgendes Diagramm veranschaulicht die Schnittstellenstruktur: Verschiebbar Distanzmessung AufFlaeche Punkt Jede Schnittstelle entspricht einem Referenztyp. Ein Objekt mit mehreren Schnittstellen hat gleichzeitig auch ebensoviele Typen. Eine Variable vom Typ Verschiebbar kann auch ein Objekt vom Typ Punkt enthalten, weil jede Instanz von Punkt auch eine Instanz von Verschiebbar ist. Ebenso kann man an eine Methode, die einen formalen Parameter vom Typ Distanzmessung hat, ein Argument vom Typ AufFlaeche bergeben. Jedes Objekt vom Typ AufFlaeche ist ja auch Instanz von Distanzmessung. Jedoch ist nicht jede Instanz von Distanzmessung auch Instanz von Verschiebbar. Falls es in obigem Diagramm einen Pfad (entlang der Pfeile) von einem tiefer stehenden zu einem hher stehenden Kstchen gibt, dann kann man eine Instanz des entsprechenden tiefer stehenden Typs in einer Variable des hher stehenden Typs ablegen.
2
In Abschnitt 3.4 werden wir sehen, dass jede Klasse zumindest eine weitere Schnittstelle hat, die der Schnittstelle der vorgegebenen Klasse Object entspricht.
202
Diese Eigenschaft, dass ein Objekt mehrere Typen haben kann, nennt man Polymorphismus (zu Deutsch etwa Vielgestaltigkeit). Von den Mglichkeiten des Polymorphismus, genauer gesagt vom enthaltenden Polymorphismus (weil jede Instanz eines Typs auch Instanz eines anderen Typs ist), auch Untertyp-Polymorphismus genannt, wird in der objektorientierten Programmierung stndig Gebrauch gemacht. Letzterer Begri kommt daher, dass man jeden Typ U als Untertyp eines Typs T bezeichnet, wenn es im Diagramm von U aus (durch Verfolgen der Pfeile) einen Pfad zu T gibt; in diesem Fall heit T Obertyp von U . Beispielsweise ist Punkt ein Untertyp von AufFlaeche. Aber Punkt ist auch ein Untertyp von Verschiebbar genauso wie von Distanzmessung. Auch AufFlaeche ist ein Untertyp von Verschiebbar und ebenso von Distanzmessung. Umgekehrt ist Verschiebbar ein Obertyp von Punkt und AufFlaeche. Zur Vereinfachung des Umgangs mit Polymorphismus gilt zustzlich, dass jeder Typ gleichzeitig Unter- bzw. Obertyp von sich selbst ist. So ist Punkt Untertyp und Obertyp von Punkt. Jede Instanz eines Untertyps des Typs einer Variablen kann in der Variable abgelegt werden. Die Vorteile des Untertyp-Polymorphismus kommen erst zum Tragen, wenn es mehrere unterschiedliche Implementierungen derselben Schnittstelle gibt. Die Klasse Scheibe in Listing 3.15 implementiert ebenso wie Punkt das Interface AufFlaeche. Im Unterschied zu einem Punkt hat eine Scheibe eine Ausdehnung, die durch den Radius der runden Scheibe festgelegt ist. Die Methoden distanzZumUrsprung und entfernung berechnen den Abstand zwischen dem Ursprung bzw. vorgegebenen Punkt und dem nchstgelegenen Punkt auf der Scheibe. Sie liefern daher 0.0 zurck, wenn der Ursprung oder gegebene Punkt auf der Scheibe liegt. Jedenfalls machen die beiden Methoden in Scheibe im Detail etwas anderes als die entsprechenden Methoden in Punkt. Zusammen mit Scheibe ergibt sich folgendes Typdiagramm so bezeichnet man ein Diagramm von Untertypbeziehungen: Verschiebbar Distanzmessung AufFlaeche Punkt Scheibe
203
3 Objektorientierte Konzepte
Listing 3.15: Weitere Implementierung von Interfaces public class Scheibe implements AufFlaeche { private double x, y, r; public Scheibe(double initX, double initY, double radius) { x = initX; y = initY; r = [Link](radius); // darf nicht negativ sein } public void verschiebe(double deltaX, double deltaY) { x = x + deltaX; y = y + deltaY; } public double distanzZumUrsprung() { return [Link](new Punkt(0.0, 0.0)); } public double entfernung(Punkt p) { Punkt q = new Punkt(x,y); double d = [Link](p); return [Link](d - r, 0.0); } }
Listing 3.16 zeigt in einem Beispiel die Verwendung von Polymorphismus. Als Parameter v der Methode bewege wird ein Objekt vom Typ Verschiebbar erwartet. Mehr braucht man an dieser Stelle nicht ber v zu wissen, da nur die in Verschiebbar spezizierte Methode aufgerufen wird. Als aktueller Parameter kann bei einem Aufruf von bewege jedes beliebige Objekt vom Typ Verschiebbar verwendet werden, beispielsweise vom Typ Punkt oder Scheibe, aber auch von jeder anderen Implementierung des Interfaces Verschiebbar, die nicht bekannt zu sein braucht. Analog dazu ist der formale Parameter von gibDistanzAus vom Typ Distanzmessung, da nichts ber dieses Objekt bekannt zu sein braucht, als dass es eine Methode distanzZumUrsprung gibt. Die Methode test bentigt einen Parameter f vom Typ AufFlaeche, da sowohl bewege als auch gibDistanzAus mit f als Argument aufgerufen werden und AufFlaeche Untertyp von Verschiebbar und gibDistanzAus ist. In main wird teste fr ein Objekt vom Typ
204
Listing 3.16: Beispiel fr Verwendung von Interfaces public class FlaechenTester { private static void bewege(Verschiebbar v) { [Link](1.5,2.5); } private static void gibDistanzAus(Distanzmessung d) { [Link]([Link]()); } public static void teste(AufFlaeche f) { for (int i=0; i < 5; i++) { bewege(f); gibDistanzAus(f); } } public static void main(String[] args) { teste(new Punkt(0.0, 0.0)); teste(new Scheibe(0.0, 0.0, 2.1)); } }
Punkt und eines vom Typ Scheibe aufgerufen. Wir brauchen also nur eine Methode um Berechnungen auf Objekten unterschiedlicher Typen durchfhren zu knnen. Das reduziert den Programmieraufwand. Wie wir spter noch sehen werden, wird dadurch auch die Wartung vereinfacht. 3.3.2 Dynamisches Binden Bei genauer Betrachtung der Methode gibDistanzAus in Listing 3.16 stellt sich die Frage, welche Methode durch [Link]() berhaupt aufgerufen wird die Methode, die in Punkt deniert ist, oder jene in Scheibe, oder vielleicht sogar eine ganz andere. Eine eindeutige Antwort darauf gibt es nicht. Nach einem Aufruf von gibDistanzAus kann die eine Methode namens distanzZumUrsprung ausgefhrt werden, beim nchsten Aufruf eine andere desselben Namens. Das hngt vom Objekt ab, fr das der formale Parameter d steht. Tatschlich ist diese Unklarheit ein Grund dafr, warum wir in der Regel vom Senden einer Nachricht an ein Objekt sprechen und nicht vom Aufruf einer Methode. Wir kennen die aufzurufende Methode im Allgemeinen ja gar
205
3 Objektorientierte Konzepte
nicht. Die Entscheidung darber, welche Methode ausgefhrt wird, trit das Objekt, an das wir die Nachricht schicken. Wenn das Objekt durch new Punkt(...) erzeugt wurde, wird distanzZumUrsprung so wie in Punkt deniert ausgefhrt, wenn es durch new Scheibe(...) erzeugt wurde, dann so wie in Scheibe deniert. Wie in diesem Beispiel kommt es oft vor, dass die auszufhrende Methode nicht schon vom Compiler bestimmt wird und immer gleich ist, sondern vom Empfnger der Nachricht abhngt, der jedesmal anders sein kann. Man spricht von dynamischem Binden, wenn die auszufhrende Methode wie im Beispiel erst zur Laufzeit bestimmt wird, und von statischem Binden, wenn bereits der Compiler die auszufhrende Methode kennt. Referenzvariablen haben in objektorientierten Sprachen mehr als nur einen Typ. Das, was wir bis jetzt kurz als Typ einer Variablen bezeichnet haben, ist eigentlich der deklarierte Typ der Variablen, also der Typ, der in der Variablendeklaration angegeben ist. Daneben gibt es den dynamischen Typ der Variablen (umgangssprachlich auch echter oder tatschlicher Typ genannt), der vom Variableninhalt abhngt: Der dynamische Typ entspricht der Klasse, als deren Instanz das Objekt in der Variablen erzeugt wurde. Beispielsweise ist der deklarierte Typ des formalen Parameters d von gibDistanzAus stets Distanzmessung. Wenn das Objekt in d durch new Punkt(...) erzeugt wurde, dann ist Punkt der dynamische Typ von d, und wenn das Objekt durch new Scheibe(...) erzeugt wurde, dann Scheibe. Der deklarierte Typ einer Variablen bleibt immer gleich, der dynamische Typ kann sich im Laufe der Zeit ndern. Fr dynamisches Binden wird immer der dynamische Typ herangezogen. Listing 3.17 zeigt ein Beispiel, wie dynamisches Binden eingesetzt werden kann. Das Interface Beurteilung speziziert zwei Methoden, die in drei Untertypen von Beurteilung implementiert werden. Die Methode test in Test1 verwendet dynamisches Binden, indem Nachrichten an den formalen Parameter b geschickt werden. Jede Aufgabe ist statt durch dynamisches Binden auch mittels if- und switch-Anweisungen lsbar. Listing 3.18 zeigt eine krzere Lsung derselben Aufgabe, die auch von Test1 in Listing 3.17 gelst wird. Mit wenig Programmiererfahrung knnte man meinen, dass die Lsung in Listing 3.18 besser wre als jene in Listing 3.17. Das ist jedoch ganz sicher nicht der Fall. Einerseits sind das Interface und die Klassen, die das Interface implementieren, zur allgemeinen Verwendung gedacht, nicht nur
206
Listing 3.17: Beispiel fr Verwendung von dynamischem Binden empfohlen public interface Beurteilung { boolean bestanden(); String toString(); } public class Auszeichnung implements Beurteilung { public boolean bestanden() { return true; } public String toString() { return "mit Auszeichnung bestanden"; } } public class Bestanden implements Beurteilung { public boolean bestanden() { return true; } public String toString() { return "bestanden"; } } public class NichtBestanden implements Beurteilung { public boolean bestanden() { return false; } public String toString() { return "nicht bestanden"; } } public class Test1 { public static String test (Beurteilung b) { String s = "Prfung " + [Link]() + "! "; if ([Link]()) return s + "Herzlichen Glckwunsch!"; else return s + "Vielleicht das nchste Mal."; } }
als Hilfsmittel zur Erstellung der Testklasse. Die Methode test in Listing 3.17 ist deutlich krzer als jene in Listing 3.18, weil die Haupt-
207
3 Objektorientierte Konzepte
Listing 3.18: Vermeidung von dynamischem Binden nicht empfohlen public class Test2 { public static final int AUSZEICHNUNG = 1; public static final int BESTANDEN = 2; public static final int NICHT_BESTANDEN = 5; public static String test (int b) { String s = "Prfung "; String e = "Da ist etwas falsch gelaufen."; switch (b) { case AUSZEICHNUNG: s += "mit Auszeichnung "; case BESTANDEN: s += "bestanden"; e = "Herzlichen Glckwunsch!"; break; case NICHT_BESTANDEN: s += "nicht bestanden"; e = "Vielleicht das nchste Mal."; break; } return s + "! " + e; } }
aufgaben von den anderen Klassen bernommen werden. Wenn man das Beispiel erweitert, kann man erwarten, dass ein Groteil des Codes, der zu Listing 3.17 hinzukommt, krzer ist als jener, der zu Listing 3.18 hinzukommt, weil im ersten Fall eher auf die bereits bestehenden Klassen zurckgegrien werden kann. Der Vorteil der Krze von Listing 3.18 kann sich im Laufe der Entwicklung des Programms in das Gegenteil verkehren. Andererseits ist die Lsung in Listing 3.18 auch recht anfllig fr Fehler: Es wird eine kaum sinnvolle Zeichenkette zurckgegeben, wenn man test mit einem anderen Argument als 1, 2 oder 5 aufruft. Auch wenn man durch einen default:-Zweig diese Flle abfangen wrde, wsste man noch immer nicht, welche besser geeignete Zeichenkette man zurckgeben knnte. Dieses Problem besteht bei einer Lsung mittels dynamischem Binden nicht, da bereits der Compiler sicherstellt, dass test nur mit Instanzen von Beurteilung aufgerufen wird. Allerdings muss man sicherstellen, dass nicht null als Argument bergeben wird.
208
Der Code in Listing 3.18 ist sehr kompakt, weil viele Mglichkeiten zum Sparen von Codezeilen ausgenutzt wurden. Etwa werden die beiden Flle von AUSZEICHNUNG und BESTANDEN zum Groteil durch gemeinsamen Code abgearbeitet. Leider geht diese Sparsamkeit auf Kosten der Lesbarkeit. Man muss davon ausgehen, dass bei einer knftigen Programmnderung Teile des Programms falsch verstanden und dadurch auf unbeabsichtigte Weise gendert werden. Jede einzelne Klasse in Listing 3.17 ist dagegen viel einfacher verstndlich und damit weniger fehleranfllig. Mehrere Aspekte des Programms sind in Listing 3.18 ineinander verwoben. So kmmert sich dieselbe switch-Anweisung um die Bezeichnung der Beurteilung (s) und den Text am Ende (e). Derartiges ist problematisch, nicht nur weil es die Lesbarkeit erschwert, sondern auch weil der gemeinsame Kontrolluss fr beide Aspekte durch Programmnderungen leicht verloren geht und kleine Erweiterungen daher wahrscheinlich groe nderungen nach sich ziehen. Das Interface muss dagegen eine logische Struktur vorgeben, sodass derartige Verwebungen verschiedener Aspekte besser verhindert werden. Es ist einfach, das Programm auf verschiedene Weisen zu erweitern, beispielsweise durch Hinzufgen einer Klasse NichtBeurteilt. Derartige Probleme treten ganz allgemein zusammen mit (meist mehrfachen) Programmverzweigungen hug auf. Zusammengefasst gilt, dass dynamisches Binden fast immer besser ist als die Verwendung von switchAnweisungen oder ineinander geschachtelten if-Anweisungen. Bevor man solche Anweisungen schreibt, sollte man sich berlegen, wie man die Aufgabe stattdessen durch dynamisches Binden lsen kann. Dynamisches Binden sorgt ebenso wie die dafr vorgesehenen Anweisungen fr mehrfache Programmverzweigungen. Es verbessert durch die Art und Weise, wie das Programm dabei in Klassen und Interfaces aufgeteilt wird, darber hinaus meist auch die Programmstruktur und Wartbarkeit. Dynamisches Binden ist aufgrund der Mehrfachverzweigung bei der Ausfhrung um eine Winzigkeit weniger ezient als statisches Binden. Manchmal ist man deswegen versucht, auf dynamisches Binden zu verzichten. Allerdings ist dynamisches Binden etwas ezienter als die Ausfhrung einer switch-Anweisung. Wenn man also statisches Binden durch eine bedingte Anweisung erkauft, hat man in der Summe wahrscheinlich an Ezienz eingebt. Ezienzberlegungen soll man hinsichtlich dynamischem Binden also prinzipiell beiseite lassen.
209
Auszeichnung S1
3.3.3 Spezialisierung und Ersetzbarkeit In Abschnitt 3.3.1 haben wir gesehen, wie man durch die Verwendung von Interfaces Untertypbeziehungen einfhren und Typdiagramme aufbauen kann. Typdiagramme geben die Struktur eines Programms vor und sind daher von entscheidender Bedeutung. In der Programmierpraxis mssen wir stndig Typdiagramme aufbauen und uns dabei fr bestimmte Strukturen und gegen andere Strukturen entscheiden. Wir bentigen Hilfsmittel um zu erkennen, wie gut ein bestimmtes Typdiagramm fr ein Programm geeignet ist. Solche Hilfsmittel wollen wir hier kurz ansprechen. Zwei eng miteinander verbundene Hilfsmittel bestehen im Verstehen von Typdiagrammen als Spezialisierungen und in der Verwendung von Analogien zur realen Welt. Zur Erklrung verwenden wir das Typdiagramm in Abbildung 3.19. Es ist leicht zu sehen, dass die Begrie, die hier verwendet werden, aus der realen Welt kommen; sie knnen auf Zeugnissen und Teilnahmebesttigungen vorkommen. Die Abkrzungen S1 bis N5 sind an der TU Wien bliche Krzel fr sehr gut bis nicht gengend. Wegen der Analogie zur realen Welt ist auch ohne Erklrungen oensichtlich, wofr diese Begrie stehen. Auch die Beziehungen zwischen den Begrien sind oensichtlich: Es zeigt nur dann ein Pfeil von einem Untertyp U zu einem Obertyp T , wenn U eine spezielle Variante von T ist; U ist also eine Spezialisierung von T . Im Beispiel ist Beurteilung der allgemeinste Begri, der fr viele unterschiedliche Beurteilungen stehen kann. Etwas spezieller ist Teilgenommen (im Unterschied zu NichtTeilgenommen), und Bestanden ist noch spezieller. Eine Unterscheidung zwischen noch spezielleren Beurteilungen als konkreten Noten, etwa B3, brauchen wir im Bei-
210
spiel nicht. Von oben nach unten werden die Begrie im Diagramm immer spezieller. Manchmal spricht man auch von ist-ein-Beziehungen. Beispielsweise ist B3 eine Beurteilung der Art Bestanden, und Bestanden ist eine Beurteilung der Art Teilgenommen. Ganz allgemein beschreibt ein Typdiagramm Spezialisierungen von Typen. Daneben muss auch gelten, dass Untertypen zumindest alle Signaturen von Methoden spezizieren mssen, die auch von den Obertypen speziziert werden. Zumindest zu Beginn der Entwurfs-Phase (siehe Abschnitt 1.6.1) kennt man die von den Typen spezizierten Methoden noch nicht. Trotzdem kann man schon ein Typdiagramm erstellen, alleine aufgrund von Spezialisierungen in der Analogie zur realen Welt. Wenn man dabei umsichtig vorgeht, passen die Signaturen der Methoden spter wahrscheinlich zusammen. Falls nicht, hat man etwas bersehen und muss die Strukturen anpassen. Obwohl das passieren kann, bilden Spezialisierungen zusammen mit Analogien zu realen Welt ein wichtiges Denkmuster, an das wir uns bei der Entwicklung von Typdiagrammen stets halten sollen. Es passiert leicht, dass man zu Beginn zu detailreiche Typdiagramme zeichnet. Mglicherweise wurde obiges Typdiagramm als Grundlage zur Erstellung von Listing 3.17 verwendet. Allerdings wurden beim Schreiben des Programmcodes viele der Typen im Diagramm weggelassen, weil sie nicht gebraucht werden. Vielleicht stellt der Code in Listing 3.17 auch nur eine Zwischenphase dar, und es kommen spter weitere Typen hinzu. Es knnte auch beides gleichzeitig zutreen: Derzeit brauchen wir nur einen kleinen Teil der Typen, spter knnten weitere Typen notwendig werden. Ein auf die reale Welt und Spezialisierungen konzentrierter Entwurf lsst sich oft relativ einfach erweitern, da man von Anfang an alles aus einem weiteren Blickwinkel betrachtet, als dies zur Lsung der Aufgabe unbedingt notwendig wre. Ein weiterer wichtiger Begri im Zusammenhang mit Spezialisierungen ist das Prinzip der Ersetzbarkeit: Ein Typ U soll genau dann Untertyp eines Typs T sein, wenn Objekte vom Typ U berall verwendbar sind, wo Objekte vom Typ T erwartet werden. Das Ersetzbarkeitsprinzip ist eng mit Spezialisierungen verknpft. Wenn U einen Spezialfall von T darstellt, dann ist jedes Objekt vom Typ U auch ein Objekt vom Typ T , und dann kann man natrlich auch jedes Objekt vom Typ U berall dort verwenden, wo eines vom Typ T erwartet wird. Im Vergleich zur Spezialisierung drckt das Ersetzbarkeitsprinzip aber etwas genauer das Wesentliche aus. Wenn nicht klar ist, ob U als Spezialisierung von T angesehen werden soll, dann gibt das Ersetzbarkeitsprinzip eine klare Antwort darauf.
211
3 Objektorientierte Konzepte
Das Ersetzbarkeitsprinzip drckt ganz direkt aus, worauf es bei Untertypbeziehungen ankommt: Wenn ein Programmstck fr eine Instanz eines Obertyps funktioniert, dann wird dieses Programmstck auch fr eine Instanz eines Untertyps funktionieren. Bei der Erstellung des Programmstcks denken wir nur an den Obertyp und brauchen nicht zu wissen, welche Untertypen es gibt. Aber bei der Einfhrung der Untertypen mssen wir uns vergewissern, dass das Ersetzbarkeitsprinzip erfllt ist. Insgesamt passen die Typen dann zusammen. Beispielsweise schreiben wir die Methode gibDistanzAus in Listing 3.16 ohne Wissen ber die Klassen, die das Interface Distanzmessung implementieren. Aber beim Schreiben von Klassen wie Punkt und Scheibe mssen wir nicht nur ein entsprechendes Interface implementieren, sondern auch dafr sorgen, dass die in Distanzmessung spezizierte Methode distanzZumUrsprung tatschlich so funktioniert wie im Interface beschrieben siehe Listing 3.12. Die Methode muss also die Distanz bis zum Ursprung zurckgeben. Die in einem Typdiagramm beschriebene Struktur nennt man auch Typhierarchie. Dieser Name ist nicht ganz optimal gewhlt, weil es sich dabei nicht unbedingt um eine hierarchische Struktur handeln muss, bei der ein Typ an der Spitze steht und sich die Struktur nach unten immer weiter verzweigt. In Abschnitt 3.3.1 haben wir Beispiele fr nicht-hierarchische Strukturen gesehen. Manchmal spricht man statt von einer Typhierarchie auch von einer Vererbungshierarchie. Dieser Name kommt von der Vererbung in objektorientierten Sprachen, die wir in Abschnitt 3.4 kennenlernen werden. Leider wird dieser Begri hug falsch verstanden: Vater Kind Dieses Diagramm zeigt keine Typ- oder Vererbungshierarchie, weil Kind keine Spezialisierung von Mutter und Vater sein kann. Der Begri Vererbung hat in diesem Zusammenhang nichts damit zu tun, dass Kinder von ihren Eltern abstammen. Ein Objekt vom Typ Kind ist, entsprechend den Erfahrungen aus der realen Welt, eine Person, die nicht gleichzeitig Mutter und Vater von sich selbst sein kann. Von einer Spezialisierung und vom Ersetzbarkeitsprinzip wrde man sich das erwarten. Solche Diagramme setzen immer Typen zueinander in Beziehung, keine Objekte. Folgende beiden Diagramme sollen noch klarer machen, was eine Typoder Vererbungshierarchie sein kann und was nicht: Mutter
212
3.4 Vererbung
Die Hierarchie auf der linken Seite stellt die evolutionre Entwicklung vom Einzeller ber den Saurier bis zum Vogel dar. Das ist keine Typ- oder Vererbungshierarchie, weil ein Vogel nicht gleichzeitig Saurier und Einzeller sein kann. Es spielt keine Rolle, dass in einem Vogel noch Erbgut eines Sauriers und Einzellers steckt. Ein Programmstck, das mit allen Einzellern umgehen kann, ist von Sauriern und Vgeln eventuell berfordert. Die Hierarchie auf der rechten Seite stellt dagegen schon eine Typ- oder Vererbungshierarchie dar, da jede Katze ein Sugetier und jedes Sugetier ein Tier ist. Ein Programmstck, das mit allen Tieren umgehen kann, kann sicher auch mit allen Sugetieren und Katzen umgehen.
3.4 Vererbung
Eines der bekanntesten und zugleich am hugsten missverstandenen Konzepte der objektorientierten Programmierung ist die Vererbung. Dabei wird eine Klasse aus einer anderen Klasse abgeleitet. Auch mittels Vererbung lassen sich Typhierarchien hnlich denen aufbauen, die wir zusammen mit Interfaces in Abschnitt 3.3 gesehen haben. Zustzlich eignet sich die Vererbung dazu, in einer Klasse denierte Methoden in eine andere Klasse zu bernehmen, also Methoden zu erben. Es braucht viel Programmiererfahrung, um den Zwiespalt zu meistern, der sich hug zwischen dem Aufbau guter Typhierarchien und dem Erben von Methoden ergibt. 3.4.1 Ableitung von Klassen Vererbung ist anhand eines Beispiels einfach zu verstehen. In Listing 3.20 wird die Klasse B3 von Bestanden abgeleitet, wobei Beurteilung aus Listing 3.17 bernommen ist. Die Klasse Bestanden deniert3 die beiden Methoden bestanden und toString. Aufgrund von Vererbung
3
Man sagt auch, Bestanden implementiert die beiden Methoden, um zu betonen, dass Anweisungen im Rumpf der Methoden vorhanden sind, die Methoden also tatschlich deniert und nicht nur deklariert sind.
213
3 Objektorientierte Konzepte
Listing 3.20: B3 als eine von Bestanden abgeleitete Klasse public class Bestanden implements Beurteilung { public boolean bestanden() { return true; } public String toString() { return "bestanden"; } } public class B3 extends Bestanden { public String toString() { return "mit \"befriedigend\" bestanden"; } public int toInt() { return 3; } }
durch die Klausel extends Bestanden erbt B3 diese beiden Methoden. Jedoch deniert B3 selbst eine Methode toString mit derselben Signatur wie in Bestanden. Die von B3 aus Bestanden ererbte Methode toString wird durch die in B3 denierte gleichnamige Methode berschrieben. Zustzlich deniert B3 die Methode toInt, die in Bestanden nicht vorkommt. Jede Instanz der Klasse B3 hat die Methoden toString und toInt so wie in B3 deniert und zustzlich die Methode bestanden so wie in Bestanden deniert. Weiters gilt, dass B3 ein Untertyp von Bestanden ist. Das ist mglich, weil die abgeleitete Klasse (man nennt sie auch Unterklasse ) durch das Erben der Methoden der Basisklasse (also der Klasse, von der abgeleitet wird, auch Oberklasse genannt) zumindest alle Methoden speziziert, die in der Oberklasse speziziert sind. In dieser Hinsicht hnelt die Ableitung von Klassen der Implementierung von Interfaces (abgesehen davon, dass jede Klasse nur von einer anderen Klasse abgeleitet wird). Man kann B3 als Teil des Typdiagramms in Abbildung 3.19 verstehen, und S1 bis N5 knnen analog dazu deniert sein, wobei S1 von Auszeichnung und N5 von NichtBestanden abgeleitet ist. Das Vererbungsbeispiel in Listing 3.21 zeigt den Umgang mit Objektvariablen und Konstruktoren. Die Klasse FarbigeScheibe erbt die Variablen und Methoden von Scheibe. Zur Veranschaulichung deklariert
214
3.4 Vererbung
Listing 3.21: Eine von Scheibe siehe Listing 3.15 abgeleitete Klasse public class FarbigeScheibe extends Scheibe { private long r; // Farbe der Scheibe als RGB-Code public FarbigeScheibe (double initX, double initY, double radius, long farbeRGB) { super(initX, initY, radius); r = farbeRGB; } public long farbe() { return r; } // OK
// public double entfernung(Punkt p) { // return (new Punkt(x,y)).entfernung(p); // } // Syntaxfehler: x und y nicht sichtbar !! }
FarbigeScheibe eine Objektvariable r, die genau so heit wie eine Variable in Scheibe. Aus zwei Grnden ergibt sich daraus kein Konikt: Einerseits ist r in Scheibe als private deklariert (so wie auch r in FarbigeScheibe, aber darauf kommt es hier nicht an) und daher auerhalb von Scheibe nicht sichtbar. In FarbigeScheibe sind x, y und r aus Scheibe nicht zugreifbar. Andererseits kann man in Untertypen Variablen deklarieren, obwohl sie dieselben Namen wie aus der Oberklasse ererbte und sichtbare Variablen haben; in diesem Fall verdecken die in der Unterklasse deklarierten Variablen jene der Oberklasse. Jede Verwendung von r in FarbigeScheibe bezieht sich auf die in FarbigeScheibe deklarierte Variable. Da x und y nicht sichtbar sind, kann entfernung nicht (wie in Listing 3.21 auskommentiert) berschrieben werden. Um entfernung durch Weglassen von // aus Listing 3.21 zu berschreiben, mssten wir x und y in FarbigeScheibe sichtbar machen, indem wir die Variablen in Scheibe so deklarieren: protected double x, y, r; Als protected deklarierte Variablen sind in abgeleiteten Klassen sichtbar. Allerdings stellt der direkte Zugri auf Variablen, die in der Oberklasse deklariert wurden, einen schlechten Programmierstil dar und soll nach Mglichkeit vermieden werden. Bjarne Stroustrup, der Entwickler von C++ und damit einer der Urvter aller stark typisierten objektorientierten Programmiersprachen einschlielich Java, hat die Einfhrung von
215
3 Objektorientierte Konzepte
protected in C++ vor einigen Jahren als einen seiner grten Fehler bezeichnet. Durch protected werden nmlich die sonst klar geregelten Zustndigkeiten der einzelnen Programmierer(innen) fr bestimmte Programmteile durchbrochen. Konstruktoren dienen hauptschlich der Initialisierung von Variablen. Dafr ist es natrlich notwendig, dass die Konstruktoren Zugri auf die Variablen haben, auch auf die privaten. Wegen der Einschrnkungen der Sichtbarkeit kann der Konstruktor in FarbigeScheibe die von der Oberklasse geerbten Variablen gar nicht selbst initialisieren. Die spezielle Anweisung super(...) in der ersten Zeile im Konstruktor ruft zum Zwecke der Initialisierung den Konstruktor der Oberklasse auf. Eine solche Anweisung kann so wie und anstatt von this(...) siehe Abschnitt 3.2.4 nur als erste Anweisung im Rumpf eines Konstruktors vorkommen. In jedem Fall wird zuerst der Konstruktor der Oberklasse ausgefhrt, und erst dann sind die anderen Anweisungen im Rumpf des Konstruktors ausfhrbar. Falls ein Konstruktor weder mit super(...) noch mit this(...) beginnt, wird implizit super() aufgerufen, bevor der eigentliche Konstruktor ausgefhrt wird. So ruft der in B3 in Listing 3.20 implizit vorhandene Default-Konstruktor B3() den ebenfalls implizit vorhandenen Default-Konstruktor Bestanden() der Oberklasse auf.4 Falls ein Konstruktor mit this(...) beginnt, wird ein anderer Konstruktor derselben Klasse ausgefhrt, der zu Beginn ebenfalls einen Konstruktor der Oberklasse ausfhrt. Jede Instanz einer Klasse wird daher immer schrittweise von oben nach unten in der Typhierarchie initialisiert. Nehmen wir an, x und y wren in FarbigeScheibe sichtbar und entfernung wre (durch Weglassen von // aus Listing 3.21) berschrieben. Dann wre indirekt auch distanzZumUrsprung gendert, da diese Methode entfernung aufruft. Obwohl distanzZumUrsprung in Scheibe und nicht in FarbigeScheibe deniert ist, wrde wegen dynamischem Binden in einer Instanz von FarbigeScheibe dennoch entfernung so wie in FarbigeScheibe deniert ausgefhrt. Beim Aufruf nicht-statischer Methoden wird immer dynamisch gebunden.
4
Default-Konstruktoren sind immer parameterlos und nur dann vorhanden, wenn kein anderer Konstruktor deniert wurde. In Scheibe gibt es einen Konstruktor mit drei Parametern und daher keinen Default-Konstruktor. Wrde man super(...) im Konstruktor von FarbigeScheibe weglassen, dann wrde implizit mittels super() ein parameterloser Konstruktor der Oberklasse aufgerufen, der jedoch gar nicht existiert. Das wrde einen Syntaxfehler ergeben. Aus demselben Grund wrde es einen Syntaxfehler geben, wenn man in FarbigeScheibe keinen Konstruktor denieren wrde.
216
3.4 Vererbung
Im Allgemeinen bietet Vererbung folgende Mglichkeiten: Alle in der Oberklasse deklarierten bzw. denierten Variablen und Methoden werden von der Unterklasse geerbt, wenn sie nicht mit dem Modier static gekennzeichnet sind. Die ererbten Variablen und Methoden sind auch in Instanzen der Untertypen vorhanden, so als ob sie in der Unterklasse deklariert bzw. deniert worden wren. Als private deklarierte bzw. denierte Variablen und Methoden sind in der Unterklasse nicht sichtbar, obwohl sie gltig sind.5 Sie sind von der Unterklasse aus nicht zugreifbar, aber die von der Oberklasse ererbten Methoden knnen darauf zugreifen. Ererbte Methoden, die in der Unterklasse sichtbar sind, knnen in der Unterklasse berschrieben werden. Dazu deniert man in der Unterklasse eine Methode mit derselben Signatur wie in der Oberklasse.6 Die Unterklasse kann die Oberklasse erweitern, also zustzliche Variablen und Methoden deklarieren bzw. denieren. Jede Deklaration einer Variablen in der Unterklasse erweitert die Oberklasse unabhngig von ererbten Variablen. Eine Denition einer Methode erweitert die Oberklasse nur dann, wenn sie keine Methode berschreibt. Wenn eine in der Unterklasse neu eingefhrte Variable denselben Namen hat wie eine ererbte Variable, so wird die ererbte Variable verdeckt. In der Unterklasse ist also nur die in der Unterklasse deklarierte Variable sichtbar. Es existieren aber beide Variablen. Beim Senden einer Nachricht an ein Objekt wird die auszufhrende Methode stets durch dynamisches Binden ermittelt.7 Beim Zugri auf eine Variable erfolgt dagegen kein dynamisches Binden.
5
Auch Variablen und Methoden mit Default-Sichtbarkeit sind in der Unterklasse nicht sichtbar, falls die Oberklasse in einem anderen Paket, also in einem anderen Verzeichnis deniert ist als die Unterklasse. 6 Die Sichtbarkeit der Methode in der Unterklasse kann jedoch weniger stark eingeschrnkt sein als die in der Oberklasse. Auerdem darf seit der Java-Version 1.5 der Ergebnistyp der Methode in der Unterklasse ein Untertyp des Ergebnistyps in der Oberklasse sein. Methoden, die mit dem Modier final versehen sind, drfen nicht berschrieben werden. 7 Davon ausgenommen sind final Methoden und alle Methoden in final Klassen siehe Abschnitt 3.4.2. Auch fr private Methoden ist dynamisches Binden nicht ntig, weil diese Methoden nicht berschrieben werden knnen.
217
3 Objektorientierte Konzepte
Beim Erzeugen eines neuen Objekts wird zuerst immer ein Konstruktor der Oberklasse ausgefhrt, dann der Konstruktor der Unterklasse. Mittels super(...) kann man den Konstruktor der Oberklasse bestimmen und Parameter weiterleiten. Statische Variablen und Methoden sind von der Vererbung nicht betroen, da sie zu den Klassen selbst und nicht zu Instanzen der Klassen gehren. hnlich verhlt es sich mit Konstruktoren. Sie werden nicht weitervererbt, sondern mssen in jeder Klasse neu deniert werden. 3.4.2 Klassen versus Interfaces Im Prinzip kann man komplexe Typhierarchien wie die in Abbildung 3.19 alleine nur mittels Vererbung ohne Zuhilfenahme von Interfaces aufbauen. Allerdings gibt es dabei eine Einschrnkung: In Java kann eine Klasse immer nur von einer anderen Klasse abgeleitet sein. Jedes Interface kann dagegen mehrere andere Interfaces erweitern, und jede Klasse kann mehrere Interfaces implementieren. Andererseits werden in Interfaces (abgesehen von Konstanten) keine Variablen deklariert und keine Methoden deniert. Auf gewisse Weise ist Vererbung mchtiger: Wenn zwei Klassen dasselbe Interface implementieren, kann es vorkommen, dass beide Klassen dieselbe Methode auf dieselbe Art und Weise denieren mssen. Wenn zwei Klassen dagegen von einer weiteren Klasse abgeleitet sind, braucht die gemeinsame Methode nur einmal in der Oberklasse deniert zu werden, und die beiden Unterklassen erben diese Methode. Aus diesem Grund wird die Ableitung von Klassen gegenber der Verwendung von Interfaces oft bevorzugt. Das funktioniert aber nur, wenn die Typen tatschlich eine Hierarchie bilden, also von jedem Typ im Typdiagramm hchstens ein Pfeil auf einen anderen Typ ausgeht. Werden zustzliche Pfeile auf weitere Obertypen gebraucht, mssen dafr Interfaces verwendet werden. Ein weiterer Unterschied besteht darin, dass durch Anwendung des Operators new Instanzen von Klassen erzeugt werden knnen, whrend von Interfaces selbst keine Instanzen erzeugbar sind. Manchmal ist das Erzeugen von Instanzen unerwnscht, obwohl man Methoden erben mchte. Fr diesen Zweck gibt es in Java Klassen, die so wie Auszeichnung in Listing 3.22 mit dem Modier abstract deniert werden. Von solchen Klassen kann keine Instanz erzeugt werden. Trotzdem gibt es zur Initialisierung auch in abstrakten Klassen Konstruktoren zumindest einen Default-Konstruktor. Weil keine Instanzen abstrakter Klassen existieren,
218
3.4 Vererbung
Listing 3.22: Abstrakte und final Klassen und Methoden public abstract class Auszeichnung implements Beurteilung { public final boolean bestanden() { return true; } public String toString() { return "mit Auszeichnung bestanden"; } public abstract int toInt(); } // sterreichische Variante: 1 ist die beste Note public final class S1 extends Auszeichnung { public int toInt() { return 1; } } // schweizer Variante: 5 ist die beste Note public class Fuenf extends Auszeichnung { public final int toInt() { return 5; } }
knnen auch Methoden dieser Klassen mit dem Modier abstrakt versehen werden. Solche abstrakten Methoden sind nicht implementiert, sondern entsprechen Signaturen wie in Interfaces. Nicht-abstrakte Klassen, die von abstrakten Klassen abgeleitet sind, mssen entsprechende Methoden denieren. Abgesehen davon werden abstrakte Klassen wie nichtabstrakte Klassen behandelt. Somit stellen abstrakte Klassen eine Zwischenform zwischen nicht-abstrakten Klassen (von denen Instanzen erzeugbar sind) und Interfaces (die keine Variablendeklarationen und vollstndigen Methodendenitionen enthalten knnen) dar. Statt mit abstract knnen Klassen und Methoden auch mit dem Modier final versehen sein, der in diesem Fall eine andere Bedeutung als im Zusammenhang mit Variablen und Konstanten hat und beinahe das Gegenteil von abstract ausdrckt: Von einer final Klasse darf keine andere Klasse abgeleitet werden, whrend eine abstrakte Klasse nur sinnvoll ist, wenn andere Klassen von ihr abgeleitet werden. Eine final
219
3 Objektorientierte Konzepte
Methode darf in einer abgeleiteten Klasse nicht berschrieben werden, whrend eine abstrakte Methode in einer nicht-abstrakten Klasse berschrieben werden muss. Es ist klar, dass abstrakte Klassen und Methoden nicht final sein knnen und abstrakte Methoden niemals in nichtabstrakten Klassen vorkommen drfen. In einigen objektorientierten Programmierstilen werden die meisten Klassen als final oder abstract gekennzeichnet, um deutlich zu machen, welche Klassen dazu gedacht sind, erweitert bzw. nicht erweitert zu werden. In anderen Programmierstilen werden final Klassen dagegen kaum verwendet, weil man annimmt, dass fast alle Klassen erweiterbar sind. Praktisch gesehen macht das wenig Unterschied, da ungeplante Ableitungen von Klassen in Java und hnlichen Sprachen kaum vorkommen. Im Vergleich zu abstrakten Methoden sind final Methoden eher nur in Ausnahmefllen sinnvoll. Listing 3.22 zeigt die Verwendung von abstract und final in einem Beispiel. Die Klasse Auszeichnung muss abstrakt sein, weil sie eine abstrakte Methode toInt enthlt. Wenn wir eine Variable x vom deklarierten Typ Auszeichnung haben, knnen wir [Link]() ausfhren; die Nachricht wird abhngig vom dynamischen Typ von x, beispielsweise S1 oder Fuenf, vom Empfnger beantwortet werden. Sowohl S1 als auch Fuenf muss toInt denieren. Die Methode toString kann in S1 und Fuenf berschrieben werden, muss aber nicht. Auch die Anweisung [Link]() wird durch dynamisches Binden abhngig vom dynamischen Typ von x ausgefhrt. Bei Bedarf ist es auch spter noch leicht mglich, die Methode zu berschreiben. Uns ist wichtig, dass die Methode bestanden in Auszeichnung stets true zurckgibt und haben sie daher als final gekennzeichnet. Dadurch kann bestanden in den Unterklassen nicht unabsichtlich berschrieben werden. Fr einen Aufruf von [Link]() reicht statisches Binden. Die Klasse S1 ist als final deniert, sodass keine Klasse von S1 ableitbar ist. Man kann alle Methoden in S1, egal ob in dieser Klasse deniert oder ererbt, als final ansehen, da sie nicht berschreibbar sind. Die Klasse Fuenf ist nicht final, wohl aber die Methode toInt in dieser Klasse sowie die ererbte Methode bestanden. Es ist zwar mglich, weitere Klassen von Fuenf abzuleiten, aber toInt und bestanden drfen dabei nicht berschrieben werden. Listing 3.23 zeigt, wie man Klassen und Interfaces miteinander kombinieren kann. Viele Typen in der Typhierarchie in Abbildung 3.19 wie beispielsweise B3 und Auszeichnung haben zwar eine Methode toInt, aber es gibt keinen gemeinsamen Obertyp all dieser Typen. Einen sol-
220
3.4 Vererbung
Listing 3.23: Kombinierte Verwendung von Klassen und Interfaces public interface Note { int toInt(); } public class B3 extends Bestanden implements Note { ... } public abstract class Auszeichnung implements Beurteilung, Note { ... } public class Test { public static void test(Note n) { [Link]([Link]()); } }
chen gemeinsamen Obertyp knnen wir ber das Interface Note einfhren. Klassen wie B3 und Auszeichnung implementieren dieses Interface. Nun ist es mglich, die Methode test in Test mit Instanzen all dieser Typen als aktuellen Parametern auszufhren, ohne dass dazu grere Umstrukturierungen der Typhierarchie in Abbildung 3.19 notwendig wren. Auf diese Weise knnen wir ber Interfaces stets zustzliche Struktur in unsere Typhierarchie bringen. Angesichts der Vielzahl an Mglichkeiten Interfaces sowie abstrakte, normale und nal Klassen stellt sich die Frage, welche Variante man in der Praxis am sinnvollsten verwendet. Weniger erfahrene Leute verwenden am besten nur Interfaces und nal Klassen, wobei die nal Klassen ganz unten in der Typhierarchie stehen. Dabei verzichtet man auf Vererbung und schreibt Teile des Programmcodes manchmal mehrfach. Mit etwas mehr Programmiererfahrung kann man Vererbung einsetzen, indem man, wo dies sinnvoll ist, das eine oder andere Interface durch eine abstrakte Klasse (mit abstrakten neben nicht-abstrakten Methoden) ersetzt. Obwohl fast alle Lehrbcher Beispiele dafr geben, wie man aus einer bestehenden nicht-abstrakten Klasse weitere sinnvolle Klassen ableiten kann, ergibt sich in der Praxis nur selten eine Gelegenheit dazu. Normale Klassen braucht man in der Praxis kaum. Gelegentlich werden solche Klassen als abstrakt angesehen, also keine Objekte davon erzeugt, obwohl dies mglich wre. Viel huger werden normale Klassen jedoch implizit als nal angesehen,
221
3 Objektorientierte Konzepte
das heit, es werden keine weiteren Klassen von ihnen abgeleitet, auch wenn kein final-Modier dabeisteht. Bei den meisten nicht-abstrakten Klassen in diesem Skriptum wurde der final-Modier wie in der Praxis nur aufgrund der krzeren Schreibweise weggelassen. 3.4.3 Von Object abwrts Alle Java-Klassen zusammengenommen bilden eine Typhierarchie, an deren Spitze die Klasse Object steht. Wenn wir eine Klasse ohne extendsKlausel denieren (also keine Oberklasse angeben), dann wird die Klasse implizit von Object abgeleitet. Dadurch hat jede Klasse im von den Klassen gebildeten Typdiagramm genau einen Pfeil auf eine andere Klasse, von der sie abgeleitet ist. Davon ausgenommen ist nur die Klasse Object selbst. Das bedeutet, dass jede Klasse direkt oder indirekt von Object abgeleitet ist und alle Methoden von Object erbt. In Object sind hug verwendete Methoden deniert. Einige davon wollen wir nher betrachten. Im Interface Beurteilung in Listing 3.17 haben wir eine parameterlose Methode toString eingefhrt, die eine Zeichenkette zur Beschreibung des Objekts vom Typ Beurteilung zurckgibt. Alle Klassen, die von Beurteilung abgeleitet sind, mssen diese Methode denieren. Tatschlich wre es gar nicht notwendig, diese Methode in Beurteilung zu spezizieren, da bereits Object eine entsprechende Methode toString deniert. Allerdings ist toString in Object so deniert, dass die Methode zwar eine lesbare Zeichenkette zurckgibt, die jedoch hauptschlich eine wenig aussagekrftige Zahl enthlt. In der Praxis ist es hug notwendig, toString zu berschreiben, damit eine aussagekrftigere Zeichenkette zurckgegeben wird. In Abschnitt 2.2.2 haben wir den Verkettungsoperator kennengelernt: Falls im Ausdruck x+y einer der beiden Operanden vom Typ String ist, dann wird auch der andere Operand in eine Zeichenkette umgewandelt, und die beiden Zeichenketten werden zu einer Zeichenkette zusammengefgt. Fr elementare Typen wie ganze Zahlen kann man sich leicht vorstellen, auf welche Weise ein Operand in eine Zeichenkette umgewandelt wird. Fr Referenztypen kann man sich das nicht so einfach vorstellen. Hier kommt die Methode toString ins Spiel: Wenn ein Operand des Verkettungsoperators von einem Referenztyp ist, dann wird das Objekt durch einen Aufruf von toString in eine Zeichenkette umgewandelt. Beispielsweise htten wir in Listing 3.17 statt String s = "Prfung " + [Link]() + "! ";
222
3.4 Vererbung
krzer und mit derselben Semantik folgende Zeile schreiben knnen: String s = "Prfung " + b + "! "; Durch dynamisches Binden wird stets die richtige, das ist die dem dynamischen Typ des Operanden entsprechende Implementierung von toString gewhlt. Damit die Umwandlung in eine Zeichenkette fr alle Objekte funktioniert, muss toString in Object deniert sein. Man darf sich aber nicht darauf verlassen, dass toString sich genau so verhlt wie in Object implementiert, da die Methode in Unterklassen berschrieben sein kann und hug tatschlich berschrieben ist. In Abschnitt 3.2.3 haben wir gesehen, dass wir streng zwischen Identitt und Gleichheit von Objekten unterscheiden mssen. Dabei haben wir auch die Methode equals kennengelernt, die in Object etwa so deniert ist: public boolean equals(Object that) { return this == that; } Das heit, so wie in Object deniert vergleicht die Methode nur auf Identitt. In vielen Klassen ist eine andere Denition fr Gleichheit sinnvoll, und diese Methode muss entsprechend berschrieben werden. Beispielsweise ist equals in String berschrieben. berraschenderweise ist es recht schwierig, equals auf vernnftige Weise zu berschreiben. Das Problem besteht darin, dass der Parameter von equals immer vom Typ Object ist, obwohl wir eigentlich nur Objekte des gleichen Typs miteinander vergleichen wollen, also beispielsweise Zeichenketten mit Zeichenketten. Zur Lsung bentigen wir zustzliche Sprachkonstrukte. Listing 3.24 zeigt, wie equals in einigen Klassen, die wir schon betrachtet haben (Listings 3.13, 3.15 und 3.21), berschrieben werden kann. Dabei wenden wir bisher noch nicht eingefhrte Sprachkonstrukte an: Typvergleich: Der Inx-Operator instanceof stellt fest, ob der linke Operand eine Instanz des Referenztyps im rechten Operanden ist, und liefert als Ergebnis einen Wahrheitswert zurck. Beispielsweise gibt o instanceof Scheibe als Ergebnis true zurck, wenn der dynamische Typ von o gleich Scheibe oder FarbigeScheibe ist; FarbigeScheibe ist Untertyp von Scheibe, und daher ist jede Instanz von FarbigeScheibe auch Instanz von Scheibe.
223
3 Objektorientierte Konzepte
Listing 3.24: berschreiben von equals public class Punkt implements Verschiebbar, Distanzmessung { private double x, y; public boolean equals(Object o) { return o instanceof Punkt && x == ((Punkt)o).x && y == ((Punkt)o).y; } ... } public class Scheibe implements AufFlaeche { private double x, y, r; public boolean equals(Object o) { return o instanceof Scheibe && r == ((Scheibe)o).r && x == ((Scheibe)o).x && y == ((Scheibe)o).y; } ... } public class FarbigeScheibe extends Scheibe { private long r; public boolean equals(Object o) { return o instanceof FarbigeScheibe && r == ((FarbigeScheibe)o).r && [Link](o); } ... }
Cast: Im Gegensatz zum Cast auf einen elementaren Typ siehe Abschnitt 2.2.3 ndert ein Cast auf einen Referenztyp den deklarierten Typ eines Ausdrucks. Beispielsweise ndert (Scheibe)o den deklarierte Typ von o, das ist Object, auf Scheibe. Damit kann man auf die Variablen x, y und r dieses Objekts zuzugreifen; der deklarierte Typ entspricht ja der Klasse, in der die Anweisung steht. Ohne Cast sind die Variablen nicht zugreifbar, da sie in Object unbekannt sind. Generell ist ein Cast auf einen Typ A nur mglich, wenn der dynamische Typ des Ausdrucks Untertyp von A (einschlielich von A selbst) ist. Das wird zur Laufzeit berprft. Ist das nicht der Fall, kommt es zu einem Laufzeitfehler siehe Kapitel 5. Deswegen wird in Listing 3.24 vor den Casts ber Typvergleiche sichergestellt, dass die Ausdrcke passende dynamische Typen haben.
224
3.4 Vererbung
Super: Bei der Denition von equals in FarbigeScheibe stehen wir vor einem Dilemma: Wir haben keinen Zugri auf die in der Oberklasse als private deklarierten Variablen. Um diese Variablen zu vergleichen, mssen wir eine in Scheibe denierte Methode aufrufen. Die Methode equals in Scheibe wrde genau das machen, was wir brauchen, aber wir knnen diese Methode nicht aufrufen, da sie in FarbigeScheibe berschrieben ist. Die Pseudo-Variable super schat Abhilfe: ber super knnen wir eine Methode der Oberklasse aufrufen, auch wenn sie in der Unterklasse berschrieben wurde, sodass [Link](o) die Methode equals, wie in Scheibe deniert, ausfhrt. Insbesondere die Verwendung von Casts auf Referenztypen gilt als sehr fehleranfllig und ist nach Mglichkeit zu vermeiden. In diesem Beispiel gibt es jedoch keine einfache andere Mglichkeit. Wir mssen damit leben, dass es nicht fr alle Probleme eine elegante Lsung gibt, und wir mssen die Fehleranflligkeit durch besondere Vorsicht kompensieren. Der Lsungsansatz in Listing 3.24 funktioniert nur, wenn wir equals in allen Klassen auf hnliche Weise berschreiben. Wenn wir das nicht machen, wird equals manchmal flschlicherweise true zurckgeben, obwohl nichteinmal die dynamischen Typen der verglichenen Objekte bereinstimmen. Wrden wir die Methode in FarbigeScheibe nicht berschreiben, dann knnte sogar ein Vergleich einer nicht-farbigen Scheibe mit einer farbigen Scheibe true ergeben. In Java sind auch Klassen Objekte. Sie sind Instanzen der Klasse Class. Die in Object denierte parameterlose Methode getClass gibt eine Referenz auf die Klasse des Objekts zurck. Statt wie in Listing 3.24 mittels instanceof auf eine Untertypbeziehung knnten wir auch das bereinstimmen der Typen durch [Link]() == [Link]() berprfen. Damit wrde die Fehleranflligkeit reduziert, wenn wir auf das berschreiben von equals vergessen, da ein Vergleich von Instanzen unterschiedlicher Klassen immer false ergeben wrde. Allerdings knnte auch diese Variante nicht verhindern, dass beim Vergleich zweier farbiger Scheiben durch eine ererbte Methode die Farbe unbercksichtigt bliebe. Die parameterlose Methode hashCode errechnet aus jedem Objekt eine Zahl vom Typ int mit der Eigenschaft, dass bei Anwendung auf gleiche Objekte auch gleiche Zahlen zurckkommen. Die Umkehrung gilt nicht, das heit, zwei Objekte knnen ungleich sein, obwohl hashCode gleiche Zahlen liefert. Zahlen mit solchen Eigenschaften werden beispielsweise im
225
3 Objektorientierte Konzepte
Zusammenhang mit Hashtabellen gebraucht siehe Abschnitt 4.3.3. Obwohl praktisch alle Beschreibungen und Manuals davor warnen, setzen unerfahrene Leute hashCode manchmal als Ersatz fr equals ein, indem sie die zurckgegebenen Zahlen miteinander vergleichen. Das darf man auf keinen Fall machen, weil ja unterschiedliche Objekte zu gleichen Zahlen fhren knnen. Solche Fehler sind schwer zu nden. hnliches gilt fr toString: Unerfahrene Programmierer vergleichen manchmal zwei Objekte, indem sie die Objekte durch toString (oder implizit durch Anwendung von + auf Zeichenketten und diese Objekte) in Zeichenketten umwandeln und die Zeichenketten mittels equals vergleichen. Auch wenn toString, so wie in Object implementiert, fr jedes Objekt eine andere Zeichenkette zurckgibt, ist die Methode in Unterklassen hug so berschrieben, dass unterschiedliche Objekte dieselbe Zeichenkette liefern. Derartige Vergleiche knnen flschlich true ergeben. Weiters gibt es in Object noch die Methode clone zum Erzeugen einer Kopie eines Objekts, die Methode finalize zum Aufrumen in nicht mehr bentigten Objekten vor Freigabe des entsprechenden Speicherbereichs, sowie die Methoden notify, notifyAll und wait in verschiedenen Varianten, die zur Synchronisation in der nebenlugen Programmierung bentigt werden. Einige dieser Methoden werden wir in spteren Kapiteln noch kurz ansprechen.
226
Lsungsansatzes darf in dem langen Zeitraum nicht verloren gehen. Man muss Programmcode also derart schreiben, dass alle wichtigen Informationen darin enthalten und leicht aundbar sind. Fr Programmierer(innen) stellt der Quellcode das wichtigste Kommunikationsmedium dar. 3.5.1 Namen, Kommentare und Zusicherungen Kommentare im Programm sind als Trger von Informationen aller Art unverzichtbar. Frher hat man sich oft mit dem Tipp an Programmieranfnger(innen) begngt, alles was im Programm passiert durch Kommentare verstndlich zu machen. Die meisten Programmier-Lehrbcher (einschlielich dieses Skriptums) verwenden Kommentare, um Leser auf interessante Programmstellen hinzuweisen und um ungebten Programmierer(inne)n ein Programmkonstrukt oder den Ablauf eines einfachen Algorithmus zu erklren. Lernende ahmen diesen Stil gerne nach. In der Praxis sind solche Kommentare jedoch oft sinnlos und sogar strend, da die an der Softwareentwicklung beteiligten Personen in der Regel ja schon recht erfahren sind und durch umfangreiche, nichtssagende Kommentare vom Wesentlichen abgelenkt werden. Es kommt nicht auf den Umfang, sondern die Qualitt der Kommentare an. Gute Kommentare beschreiben das, was gebte Entwickler(innen) nicht mit einem Blick aus dem Programmcode herauslesen knnen oder im Normalfall nicht wissen, aber zum Verstndnis des Programms wissen mssen. Auch Namen von Klassen, Methoden und Variablen geben gebten Entwickler(inne)n viel Information und sollen daher passend gewhlt werden. Namen stellen eine Analogie zur realen Welt her und wirken auf der intuitiven Ebene. Etwas, was intuitiv klar ist, braucht man nicht mehr durch Kommentare zu erklren, oder zumindest werden die Kommentare einfacher. Namen sollen Kommentare untersttzen. Schlecht gewhlte Namen fhren leicht zu einer falschen Intuition, zu einem falschen Programmverstndnis und schlielich zu Fehlern. Wenn man keinen gut geeigneten Namen ndet, ist es besser, einen nichtssagenden Namen zu whlen als einen, der leicht falsch verstanden werden kann. Nicht nur die Art, sondern vor allem auch die Aundbarkeit von Informationen in einem Programm ist von groer Bedeutung, da man nicht davon ausgehen kann, dass eine einzelne Person jedes Detail eines groen, oft Hunderttausende oder Millionen von Zeilen umfassenden Quellcodes kennt. Ein gutes Programm enthlt genau die Informationen, die man braucht, an genau der Stelle, an der man sie erwartet.
227
3 Objektorientierte Konzepte
Die Faktorisierung spielt dabei eine wesentliche Rolle. In einem gut faktorisierten Programm ndet man sich rasch zurecht, bei schlechter Faktorisierung wird man Informationen trotz guter Kommentare nur schwer nden. Wir werden uns in Abschnitt 3.5.2 mit Kriterien zur Unterscheidung zwischen guten und schlechten Faktorisierungen auseinandersetzen. Zuvor betrachten wir einen anderen Aspekt: So wie das ganze Programm gut faktorisiert sein muss, so mssen auch die Kommentare strukturiert sein um sich zurechtzunden. Man muss wissen, wo man nach einem bestimmten Kommentar suchen soll um bestimmte Informationen zu erhalten. Wichtige Punkte, an denen man nach Informationen sucht, sind Schnittstellen zwischen Programmteilen. In Java sind das Denitionen bzw. Deklarationen von Klassen, Interfaces, Methoden, Konstruktoren und Objektvariablen. Meist schreibt man Kommentare unmittelbar davor hin oder ganz am Anfang in den Rumpf, bei kurzen Denitionen bzw. Deklarationen manchmal auch danach. Folgende Informationen sollten in den Kommentaren enthalten sein: Klassen und Interfaces: An dieser Stelle sucht man nach allgemeinen Informationen zum entsprechenden Typ. Kommentare beschreiben den Zweck und die Grobstruktur von Objekten dieses Typs. Besonderheiten in der Benutzung mssen herausgestrichen werden. Methoden und Konstruktoren: Hier erwartet man alle Informationen, die man beim Schicken einer entsprechenden Nachricht bzw. zum Erzeugen eines Objekts bentigt. Wenn dies durch die Namen und den Kontext nicht oensichtlich ist, soll kurz erklrt sein, welche Bedeutungen die Parameter haben, unter welchen Bedingungen und mit welchen Argumenten man eine Nachricht schicken oder ein Objekt erzeugen kann, was die Methode oder der Konstruktor macht und was ein mglicher Ergebniswert besagt. Objektvariablen: Wenn dies aus den Namen und dem Kontext nicht klar hervorgeht, ist eine kurze Erklrung des Zwecks sinnvoll. Vor allem mssen Eigenschaften der Variableninhalte beschrieben sein, die fr die Konsistenz des Objekts notwendig sind. Informationen, die leicht aus der Syntax herauszulesen sind (etwa die Oberklasse, Typen von Parametern, etc.), sollen nicht in Kommentaren wiederholt werden. Wichtig sind dagegen Informationen, die man zur Benutzung und Erzeugung eines Objekts braucht nicht so sehr die internen
228
Listing 3.25: Kommentare in einer Klasse siehe Listing 3.15 // Eine Scheibe ist ein kreisrundes zweidimensionales Objekt auf // einer Flche. Es ist auf der Flche verschiebbar. public class Scheibe implements AufFlaeche { private double x, y; // die beiden Koordinaten (kartesisch) private double r; // nicht-negativer Radius der Scheibe // Der Mittelpunkt der erzeugten Scheibe liegt anfangs auf // den kartesischen Koordinaten "initX" und "initY". // Der Absolutwert von "radius" bestimmt den Scheibenradius. public Scheibe(double initX, double initY, double radius) { ... } // Verschiebe die Scheibe auf der Flche um deltaX Einheiten // nach rechts und deltaY Einheiten nach oben, bei negativen // Werten in die Gegenrichtung (also links bzw. unten). public void verschiebe(double deltaX, double deltaY) { ... } // Bestimme die Entfernung vom Ursprung des Koordinatensystems // bis zum am nchsten gelegenen Punkt auf der Scheibe. // Ergebnis ist 0.0 wenn die Scheibe ber dem Ursprung liegt. public double distanzZumUrsprung() { ... } // Bestimme die Entfernung des Punktes p bis zum am nchsten // gelegenen Punkt auf der Scheibe. // Ergebnis ist 0.0 wenn p auf der Scheibe liegt. public double entfernung(Punkt p) { ... } }
Angelegenheiten. Daher sind Kommentare zu privaten Einheiten weniger wichtig als zu berall sichtbaren und verwendbaren Einheiten. Listing 3.25 gibt ein Beispiel fr Kommentare in einer Klasse. Die Rmpfe des Konstruktors und der Methoden wurden entfernt um zu demonstrieren, dass die in den Signaturen und Kommentaren gegebenen Informationen ausreichen um ein Objekt dieser Klasse zu erzeugen und zu verwenden. Genau fr diesen Zweck setzen wir Kommentare am sinnvollsten ein.
229
3 Objektorientierte Konzepte
Wie wir bereits in Abschnitt 1.5.4 gesehen haben, kann man Kommentare auch als Zusicherungen verstehen. Zusicherungen beschreiben Bedingungen, die eingehalten werden mssen. In der Regel unterscheiden wir nicht zwischen Zusicherungen und Kommentaren, die einfach nur nhere Erluterungen geben. Wir mssen davon ausgehen, dass ein Leser jeden Kommentar als Zusicherung versteht und sich darauf verlsst. So wie schlecht gewhlte Namen knnen auch ungenaue oder unzutreende erluternde Kommentare zu Fehlern fhren. Falsche Kommentare sind viel gefhrlicher als keine Kommentare. Als Zusicherungen betrachtet kann man Kommentare an Objektschnittstellen auch in folgende Kategorien einteilen: Vorbedingungen: Das sind Bedingungen in Methoden und Konstruktoren, die vor der Ausfhrung erfllt sein mssen. Meist wird verlangt, dass Argumente bestimmte Eigenschaften haben. Dadurch wird die Implementierung der Methoden und Konstruktoren vereinfacht. Beispielsweise knnten wir in Listing 3.25 verlangen, dass radius im Konstruktor einen nicht-negativen Wert hat, sodass man auf die Berechnung des Absolutwertes im Rumpf des Konstruktors verzichten kann. Methoden und Konstruktoren sind jedoch einfacher zu verwenden, wenn es keine oder mglichst wenige Vorbedingungen gibt. Daher kommen wir in Listing 3.25 ohne Vorbedingungen aus und nehmen einen etwas hheren Implementierungsaufwand in Kauf. Nachbedingungen: Das sind Bedingungen in Methoden und Konstruktoren, die nach der Ausfhrung erfllt sein mssen. Nachbedingungen beschreiben, was die Methoden und Konstruktoren machen und welche Ergebnisse zurckgegeben werden. Aufrufer verlassen sich darauf. Alle Kommentare beim Konstruktor und bei den Methoden in Listing 3.25 stellen Nachbedingungen dar. Invarianten: Das sind Bedingungen auf Variablen und auf Klassen und Interfaces, die stets eingehalten werden mssen, damit die Programmzustnde konsistent sind. Ein Beispiel ist die Bedingung, dass r in Listing 3.25 keinen negativen Wert hat. Auch die Eigenschaft, dass eine Scheibe ein kreisrundes zweidimensionales Objekt ist, kann als Invariante aufgefasst werden. Whrend der Ausfhrung einer Methode und vor der Initialisierung des Objekts darf eine Invariante kurzfristig verletzt sein. Aber nach Ausfhrung jeder Methode und jeden Konstruktors mssen alle Invarianten erfllt sein.
230
History-Constraints: Sie hneln Invarianten, schrnken aber die Entwicklung von Objekten im Laufe der Zeit ein. Beispielsweise knnte ein History-Constraint besagen, dass die Zahl in einer Variablen zwar immer kleiner, aber niemals grer werden darf. Ein anderer HistoryConstraint knnte verlangen, dass eine Methode nur unmittelbar nach einer bestimmten anderen Methode ausgefhrt wird. Oft stehen History-Constraints in Beschreibungen von Klassen und Interfaces, manchmal auch bei Methoden und Variablen. Whrend Vor- und Nachbedingungen sowie Invarianten schon lange voneinander unterschieden werden, haben sich History-Constraints erst vor kurzem als eigene Kategorie etabliert und sind daher nicht so bekannt. Die Unterscheidung zwischen diesen Arten von Zusicherungen ist wichtig, weil sie zu unterschiedlichen Zeitpunkten gelten mssen. Vorbedingungen gelten vor der Ausfhrung, Nachbedingungen danach und Invarianten im Wesentlichen davor und danach, whrend History-Constraints die Entwicklung eines Objekts generell beschrnken. Namen und Kommentare knnen altern. Das bedeutet, dass sie, obwohl ursprnglich sorgsam und gut gewhlt, im Laufe der Zeit durch nderungen des Programmcodes an Aussagekraft verlieren oder sogar ihre Korrektheit einben. Es ist notwendig, bei Programmnderungen auch Kommentare zu bercksichtigen und gegebenenfalls anzupassen. Manchmal muss man Variablen, Methoden, Klassen, Schnittstellen, etc. umbenennen. Nicht jede wichtige Zusicherung steht ausdrcklich im Programmtext. Vor allem eine Zusicherung muss immer eingehalten werden, obwohl sie nirgends explizit ausgedrckt ist: Eine Methode darf den Zustand eines Objekts niemals auf unerwartete Weise ndern. Beispielsweise darf eine Methode wie entfernung zur Berechnung des Abstands zwischen zwei Punkten die Koordinaten dieser Punkte nicht ndern. Eine solche nderung wrde niemand erwarten. Genau deswegen steht auch nichts darber in den Zusicherungen. Manchmal kommt jemand auf die Idee, unerwartete nderungen vorzunehmen um dadurch eine Aufgabe einfacher lsen zu knnen, da dadurch scheinbar keine Zusicherung verletzt wird. Trotzdem wird dabei eine wichtige, aber implizite Zusicherung verletzt. Verletzungen der impliziten Zusicherung haben meist schwerwiegende negative Auswirkungen. Methoden wie verschiebe drfen die Position des Punktes natrlich verndern, da die nderung alleine schon aufgrund des Namens der Methode erwartet wird. Explizite Nachbedingungen machen zustzlich klar, dass man nderungen erwarten muss.
231
3 Objektorientierte Konzepte
Erfahrene Programmierer(innen) verlassen sich nicht unvoreingenommen auf Namen und Kommentare. Sie berprfen gelegentlich, wie gut die Namen und Kommentare den tatschlichen Sachverhalt treen. Entsprechend dem Prfergebnis bewerten sie ihr Vertrauen in das Programm. Vertrauen auf andere Programmierer(innen) ist eine Grundvoraussetzung bei der Entwicklung groer Programme, da man ohne Vertrauen lieber Programmteile nocheinmal schreibt als bereits vorhandene Programmteile zu verwenden, oder zumindest unntige berprfungen einbaut. Aus diesem Grund knnen schlecht gewhlte Namen und Kommentare zu viel unntigem Programmcode und zu langen Entwicklungszeiten fhren. Gute Namen und Kommentare sind daher ein essentielles Qualittsmerkmal. 3.5.2 Faktorisierung, Zusammenhalt und Kopplung Wie schon mehrfach betont ist die Faktorisierung (also die Zerlegung eines Programms in kleinere Einheiten) von entscheidender Bedeutung. Ein Programm ist dann gut faktorisiert wenn alle zusammengehrigen Eigenschaften und Aspekte des Programms zu bersichtlichen Einheiten zusammengefasst sind und Eigenschaften und Aspekte, die nichts miteinander zu tun haben, so klar voneinander getrennt sind, dass sie unabhngig voneinander gendert werden knnen. Gute Faktorisierung erhht die Wahrscheinlichkeit fr lokale Programmnderungen. Bei einer lokalen nderung braucht man das Programm nur an einer Stelle zu betrachten und kann es auch an dieser Stelle anpassen. Eine nichtlokalen nderung verlangt die Betrachtung mehrerer oder vieler Programmstellen und gleichzeitige Anpassungen an mehreren Stellen. Faktorisierung und Wartbarkeit sind daher eng miteinander verknpft. Die Forderung nach einer guten Faktorisierung ntzt uns nicht viel, solange wir keine einfachen und schon frhzeitig heranziehbaren Beurteilungskriterien dafr haben, ob die Faktorisierung gut ist. Im Nachhinein, also nachdem wir die Programmnderungen schon durchgefhrt haben, ntzt uns das Wissen darber, wie einfach die nderungen durchzufhren waren, nicht mehr viel. Leider gibt es keine klar nachvollziehbaren derartigen Beurteilungskriterien. Es gibt jedoch grobe Faustregeln, die uns oft, aber nicht immer den richtigen Weg weisen. Zwei solche Faustregeln betrachten wir etwas nher:
232
Klassenzusammenhalt: Darunter versteht man den Grad des Zusammenhangs zwischen den Inhalten einer Klasse. Die Inhalte entsprechen, vereinfacht ausgedrckt, den Variablen und Methoden der Klasse, aber auch den Kommentaren, welche diese Inhalte beschreiben. Der Klassenzusammenhalt ist hoch, wenn alle Variablen und Methoden gut zusammenpassen und die Namen und Kommentare den Zweck und die Verwendung von Instanzen der Klasse treend beschreiben. Ist der Klassenzusammenhalt hoch, so verringert er sich deutlich wenn man eine Gruppe von Variablen und Methoden entfernt oder hinzufgt, sinnndernd umbenennt oder Kommentare inhaltlich ndert. Bei niedrigem Klassenzusammenhalt haben derartige nderungen nur geringfgige Auswirkungen auf den Klassenzusammenhalt oder knnen ihn sogar erhhen. Durch gedanklich durchgefhrte nderungen der Klasse kann man den Klassenzusammenhalt grob abschtzen. Das Ziel ist ein mglichst hoher Zusammenhalt aller Klassen in einem Programm. Er weist auf gute Faktorisierung hin. Objektkopplung: Das ist die Strke der Abhngigkeiten der Objekte voneinander. Es werden alle Objekte in einem laufenden System gemeinsam betrachtet. Die Objektkopplung ist stark, wenn die Objekte viele nach auen sichtbaren Methoden und Variablen haben, viele Nachrichten an andere Objekte (nicht this) geschickt und Variablen in anderen Objekten zugegrien werden, und die Anzahl der Parameter dieser Methoden hoch ist. Auch die Objektkopplung lsst sich gedanklich ganz gut abschtzen. Das Ziel ist eine mglichst schwache Objektkopplung. Sie weist darauf hin, dass notwendige nderungen des Programms eher lokal bleiben. Die Begrie Klassenzusammenhalt und Objektkopplung sind schon lange in Verwendung, haben sich aber bisher einer exakten Denition erfolgreich widersetzt. Der Klassenzusammenhalt beschreibt einfach nur den von uns gefhlten Grad des Zusammenhangs zwischen den Klasseninhalten, keineswegs einen auf einer Skala messbaren, normierten Grad. Genauso entspricht die Objektkopplung einer geschtzten Strke der Abhngigkeiten zwischen den Objekten. Es gibt zwar zahlreiche Versuche, diese Begrie auf eine wohldenierte, normierte Basis zu stellen, allerdings mit wenig Erfolg. Im Gegensatz zu gemessenen Gren tendieren Abschtzungen dazu, dass wir unbewusst Gewichtungen vornehmen. Aspekte, die uns wichtig erscheinen, gehen strker in die Abschtzung ein. Vielleicht ist eine grobe Abschtzung einer gemessenen Gre deswegen oft berlegen.
233
3 Objektorientierte Konzepte
Tatschlich drfte auch ein anderer Grund dafr ausschlaggebend sein, dass wir uns auf grobe Abschtzungen und kaum denierte Kriterien verlassen: Wir vergleichen im Kopf unterschiedliche Lsungsanstze schon lange bevor es Programmcode gibt, in dem wir etwas messen knnten. Im direkten Vergleich zweier Anstze kann man schon recht frh abschtzen, welcher Ansatz wahrscheinlich zu einem hheren Klassenzusammenhalt und einer schwcheren Objektkopplung fhren wird. Auf Basis dieser Abschtzungen treen wir unsere Designentscheidungen. Genauere Denitionen der Begrie wren dafr kaum hilfreich. Tendenziell besagt ein hoher Klassenzusammenhalt, dass ein Programm schon eine recht gute Struktur hat und es daher wahrscheinlich nicht mehr ntig sein wird, grere Verbesserungen an der Struktur vorzunehmen. Bei schwcherer Objektkopplung ist die Wahrscheinlichkeit hher, dass eine nderung in einer Klasse keine weiteren nderungen in anderen Klassen notwendig macht. Glcklicherweise stehen diese beiden Begrie in engem Zusammenhang zueinander: Oft ist der Klassenzusammenhalt genau dann hoch, wenn die Objektkopplung schwach ist, und umgekehrt. Das kommt daher, dass eine Programmstruktur, also die Faktorisierung genau dann als gut betrachtet wird, wenn mglichst viele nderungen lokal durchfhrbar sind. Nicht selten ist der Klassenzusammenhalt einfacher gefhlsmig fassbar als die Objektkopplung, weil man nur einzelne Klassen und nicht das ganze System zu betrachten braucht. Dennoch lassen sich Rckschlsse auf die Objektkopplung ziehen. Auch die beste Faktorisierung kann nicht garantieren, dass alle Programmnderungen lokal durchfhrbar sind. Wenn man beispielsweise die Signatur einer nicht-privaten Methode ndert sagen wir, verschiebe aus Listing 3.25 bekommt einen zustzlichen Parameter dann ist davon nicht nur die Denition der Methode selbst betroen, sondern auch jede Programmstelle, an der eine entsprechende Nachricht geschickt wird. Alle diese Programmstellen muss man nden und ndern. Generell wirken sich nderungen an Schnittstellen immer auf mehrere oder viele Stellen im Programm aus. Daher soll man sich bemhen, Schnittstellen so stabil wie mglich zu halten, sie also niemals ohne wichtigen Grund zu ndern. Zu den Schnittstellen gehren auch die Kommentare an den Schnittstellen. Wenn man beispielsweise den Kommentar zu verschiebe in Listing 3.25 so abndert, dass positive Werte von deltaX eine Verschiebung nach links bewirken, dann ergeben sich daraus nicht nur nderungen der Methode, sondern auch nderungen an allen Stellen, an denen eine solche Nachricht geschickt wird genauso wie bei einer nderung der Signatur.
234
Wer einmal begrien hat, was ein Kommentar bewirkt und welche Auswirkungen eine nderung eines Kommentars haben kann, wird in diesem Zusammenhang kaum mehr das Wort harmlos in den Mund nehmen. Auswirkungen einer nderung eines Kommentars sind selten lokal. Eine nderung in einer Methode, bei der die Signatur und die Kommentare unverndert bleiben knnen, ist dagegen wirklich recht harmlos. nderungen an privaten Methoden sind auch dann relativ harmlos, wenn die Signatur und die Kommentare davon betroen sind. Alle Aufrufe erfolgen ja nur innerhalb der Klasse, und weitere ntige nderungen bleiben auf die Klasse beschrnkt. Bei schlechter Faktorisierung haben wir insgesamt mehr nicht-private Methoden und eine hhere Wahrscheinlichkeit dafr, dass nderungen an deren Schnittstellen ntig sind. 3.5.3 Ersetzbarkeit und Verhalten Wir wissen aus Abschnitt 3.3.3, dass Ersetzbarkeit eine entscheidende Rolle fr Untertypbeziehungen spielt: Ein Typ U ist genau dann Untertyp eines Typs T , wenn Objekte vom Typ U berall verwendbar sind, wo Objekte vom Typ T erwartet werden. Dieses Ersetzbarkeitsprinzip ist nicht automatisch erfllt, wenn die Signaturen zweier Objekte zusammenpassen. Darber hinaus muss auch gelten, dass sich alle Methoden in jedem Objekt vom Typ U so verhalten, wie man es sich von entsprechenden Methoden in Objekten vom Typ T erwartet. Die Kompatibilitt der Signaturen wird vom Compiler berprft. Fr die berprfung der Kompatibilitt des Verhaltens sind ausschlielich wir beim Programmieren verantwortlich. Wir mssen dafr sorgen, dass eine extends- oder implements-Klausel nur dort verwendet wird, wo das Verhalten kompatibel ist. Machen wir das nicht, kommt es zu schweren Fehlern. Um bezglich des Verhaltens kompatibel zu sein muss eine Methode im Untertyp dasselbe machen wie die entsprechende Methode im Obertyp. Allerdings kann die Methode im Untertyp diese Aufgabe auf andere Weise erledigen als die im Obertyp. Beispielsweise verlangt der Kommentar der Methode distanzZumUrsprung im Interface Distanzmessung in Listing 3.12, dass die Distanz zum Ursprung zurckgegeben wird. Jede Implementierung dieser Methode muss das machen. Jedoch ist die Beschreibung im Interface ungenau. Die Methode distanzZumUrsprung in der Klasse Scheibe in Listing 3.25 przisiert die Beschreibung derart, dass die Distanz zum nchstgelegenen Punkt der Scheibe zurckgegeben wird. Der Kommentar in der Klasse ist mit jenem im Interface kompatibel.
235
3 Objektorientierte Konzepte
Wie in diesem Beispiel geht man davon aus, dass das Verhalten von Methoden durch deren Namen und Kommentare so weit beschrieben ist, dass man fr die Verwendung der Methoden (also das Senden entsprechender Nachrichten) nichts Zustzliches wissen muss. Namen und Kommentare spezizieren das Verhalten. Genau diese Spezikationen des Verhaltens sind fr die Einhaltung des Ersetzbarkeitsprinzips entscheidend. Um festzustellen, ob zwei Spezikationen von Methoden kompatibel sind, vergleichen wir die Kommentare, da die Namen ohnehin gleich sein mssen. Die Kommentare mssen inhaltlich bereinstimmen, abgesehen davon, dass Kommentare im Untertyp prziser sein drfen als im Obertyp. Ersetzbarkeit hilft dabei, unterschiedliche Teile eines Programms voneinander zu entkoppeln. Folgende Grak macht das deutlich: Anwender .. . deklarierter Typ dynamischer Typ
Ein Programmstck, hier Anwender genannt, sendet Nachrichten an ein Objekt, von dem nur der deklarierte Typ bekannt ist. Der Anwender hat ber den Empfnger nur Informationen, die im deklarierten Typ enthalten sind, und die garantieren, dass das Objekt entsprechende Methoden ausfhren kann dargestellt durch den Pfeil nach rechts. Der dynamische Typ ist Untertyp des statischen Typs dargestellt durch den Pfeil nach oben. Sowohl der Anwender als auch der dynamische Typ sind ber Pfeile an den statischen Typ gekoppelt, aber der dynamische Typ bleibt dem Anwender trotzdem verborgen angedeutet durch die gepunktete Linie. Der wichtigste Vorteil dieser Entkopplung wird klar, wenn wir den dynamischen Typ gegen einen anderen Untertyp des deklarierten Typs austauschen: Fr den Anwender ndert sich dadurch genau gar nichts. Wenn wir nderungen am dynamischen Typ vornehmen, sodass die Untertypbeziehung erhalten bleibt, werden keinerlei nderungen am Anwender notwendig. Solange wir den deklarierten Typ (einschlielich aller Kommentare) unverndert lassen und fr eine Untertypbeziehung (einschlielich kompatiblen Verhaltens) sorgen, bleiben nderungen des dynamischen Typs lokal und damit harmlos. Besonders wichtig ist die Entkopplung dann, wenn es Anwender gibt, die wir gar nicht kennen. Dann mssen wir den deklarierten Typ auf jeden Fall unverndert lassen. Mittels Entkopplung knnen Untertypbeziehungen viel zur Wartbarkeit beitragen. Die Wartbarkeit ist besser, wenn Objektvariablen und formale Parameter mit stabilen Typen deklariert sind, die sich im Laufe der
236
Zeit kaum ndern. Stabil sind vor allem Typen, die hug verwendet werden und deswegen bereits gut getestet sind. Generell sind Typen weiter oben in der Typhierarchie stabiler als solche weiter unten. Fr deklarierte Typen verwendet man besser Interfaces oder abstrakte Klassen (die nur dem einen Zweck dienen, der bentigt wird) als mit spezialisierten Klassen (deren vielfltige Verwendungsmglichkeiten man gar nicht braucht). Dadurch bleiben notwendige nderungen viel huger lokal. Programmieranfnger glauben oft, das Erben von Methoden aus einer Oberklasse wrde eine bedeutende Einsparung bringen, was die Gre des zu schreibenden Programmcodes betrit. Tatschlich sind die Einsparungen durch das Erben selbst meist sehr bescheiden. Dagegen bringen die Untertypbeziehungen, die durch das Ableiten von Klassen oder Implementieren von Interfaces eingefhrt werden, hug wirklich groe Einsparungen. Damit ist gemeint, dass ein einziges Programmstck (etwa der Anwender in obiger Grak) mit vielen Objekten unterschiedlicher dynamischer Typen umgehen kann. Wenn es die Unterscheidung zwischen dem deklarierten und dynamischen Typ nicht gbe, msste man einen eigenen Anwender fr jeden bentigten Typ schreiben. Das wre ein groer Aufwand, den man sich durch die Untertypbeziehungen erspart. Daraus kann man eine Empfehlung ableiten: Beim Aufbau einer Typhierarchie soll man nicht darauf achten, mglichst viele Methoden von einer Oberklasse zu erben. Stattdessen soll man dafr sorgen, dass jeder Anwender einen deklarierten Typ haben kann, der den tatschlichen Bedrfnissen entspricht. Dabei muss man sich ganz auf das Verhalten der Methoden konzentrieren, also dafr sorgen, dass jede Methode in einem Untertyp ein zur entsprechenden Methode im Obertyp kompatibles Verhalten hat. Wenn man so vorgeht, entstehen Typhierarchien, die fr eine gute Entkopplung sorgen und in denen Typen weit oben in der Typhierarchie eher stabil sind. Anfangs hat man vielleicht das Gefhl, dass man durch eine hhere Anzahl von Klassen und Interfaces unntigen Programmcode schreibt. Letztendlich erspart man sich jedoch das Schreiben von viel Programmcode, und das Programm wird einfacher wartbar.
237
3 Objektorientierte Konzepte
gen, die sich diesem Thema annehmen. Die Objektorientierte Modellierung hat einen Schwerpunkt im Entwurf von Klassen und Typhierarchien und deren Darstellung. In Objektorientierte Programmiertechniken geht es um die Konstruktion gut wartbarer Software mit Hilfe des objektorientierten Programmierparadigmas. Zahlreiche weitere Lehrveranstaltungen setzen gute objektorientierte Programmierkenntnisse voraus. Objektorientiert programmieren zu lernen ist ein langwieriger Prozess. Man darf sich nicht erwarten, damit gleich von Anfang an grere Softwareprojekte realisieren zu knnen. Es braucht Zeit, bis man die dafr notwendige Erfahrung hat. Aus diesem Grund ist dieses Kapitel so aufgebaut, dass man erfhrt, welche Ziele man in der objektorientierten Programmierung verfolgt und welche Konzepte und Techniken dahinter stecken, die entsprechenden Sprachkonstrukte von Java kennen und auf kleine Aufgaben anwenden lernt, eine grobe Vorstellung davon bekommt, wie man in der der objektorientierten Programmierung vorgeht und worauf es dabei ankommt. Mit den hier vermittelten Kenntnissen kann man noch lange keine guten objektorientierten Programme schreiben. Aber man sollte zumindest wissen, warum Java-Programme so aufgebaut sind, wie wir sie kennengelernt haben, und wozu die Sprachkonstrukte da sind. Diese Kenntnisse reichen zum Schreiben eigener kleiner Programme aus. Wenn man viel programmiert und dabei auch die objektorientierten Sprachkonstrukte einsetzt und deren Ziele im Blick behlt, bekommt man mit der Zeit ein Gefhl fr die richtige Verwendung. Man entwickelt seinen eigenen Programmierstil. Spter im Studium, wenn man an grere Softwareprojekte herangeht und die objektorientierten Konzepte gezielt einsetzen muss, ist ein aus eigener Erfahrung entwickelter Programmierstil von unschtzbarem Vorteil. Dieser Programmierstil wird sich stndig weiterentwickeln. Die wichtigste Empfehlung lautet daher wieder einmal, viel zu ben und praktische Erfahrung zu sammeln. Dabei darf man sich nicht von kleinen Rckschlgen entmutigen lassen. Oft lernt man aus Fehlern mehr als aus erfolgreichen Versuchen. Das gilt besonders in der objektorientierten Programmierung, in der es eine riesige Zahl an Mglichkeiten gibt, von denen aber nur wenige wirklich gut sind. Man wird in viele Fallen tappen bevor man die grten Fallen kennt und einen guten Weg zwischen den Fallen hindurch ndet.
238
3.6.1 Kontrollfragen Wodurch unterscheidet sich der objektorientierte vom prozeduralen Programmierstil? Was ist ein Objekt? Fr welche Arten von Programmen eignet sich die objektorientierte Programmierung gut, fr welche nicht? Mit welchen Problemen muss man bei der Entwicklung groer Programme rechnen? Was versteht man unter inkrementeller Softwareentwicklung? Was bedeutet der Begri Faktorisierung? Wann ist eine Faktorisierung gut, wann nicht? Wodurch unterscheiden sich Objektvariablen von lokalen Variablen? Was ist und wozu dient Kapselung, Data-Hiding und Datenabstraktion? Was ist eine Nachricht, und warum spricht man vom Senden von Nachrichten und nicht einfach nur vom Aufrufen von Methoden? Was versteht man unter einer Schnittstelle eines Objekts, was unter seiner Implementierung? Was sind und wozu verwendet man Klassen? Wie kann man in Java die Sichtbarkeit beeinussen? Wo sollen die meisten Objektvariablen sichtbar sein? Was haben Getter- und Setter-Methoden mit Data-Hiding zu tun? Erklren sie die Begrie Identitt, Zustand und Verhalten. Wie vergleicht man in Java Objekte auf Identitt bzw. Gleichheit? Wozu dienst ein Konstruktor und wie deniert man ihn? Wofr verwendet man die Pseudovariable this sowie Ausdrcke der Form this(...)?
239
3 Objektorientierte Konzepte
Wie setzt man statische Methoden und Klassenvariablen ein? Was unterscheidet Konstanten von Klassenvariablen? Wozu dienen Interfaces? Was meint man, wenn man von der Implementierung eines Interfaces spricht? Wann spricht man von Polymorphismus? Welche Rolle spielt der Polymorphismus in der objektorientierten Programmierung? Unter welchen Bedingungen ist ein Typ U Untertyp eines Typs T ? Wozu bentigt man dynamisches Binden? Inwiefern hngt dynamisches Binden mit Mehrfachverzweigungen zusammen? Warum ist dynamisches Binden gegenber switch-Anweisungen zu bevorzugen? Welchen Zweck haben Spezialisierungen und Analogien zur realen Welt? Was besagt das Ersetzbarkeitsprinzip? Was versteht man unter Vererbung? Erklren Sie die Begrie Basisklasse, abgeleitete Klasse, Unterklasse und Oberklasse. Was ist eine berschriebene Methode? Warum deklariert man Variablen nicht generell als protected? Wie werden Objekte abgeleiteter Klassen initialisiert? Wozu dient super(...) und wo kann diese Anweisung verwendet werden? Unterscheidet sich ein Methodenaufruf von einem Variablenzugri hinsichtlich dynamischem Binden? Zu welchem Zweck kann man Klassen und Methoden mit einem Modier abstract bzw. final versehen?
240
Wie kann man durch Interfaces zustzliche Struktur in ein Programm bringen? Welche Methoden sind in Object vorhanden und welchen Zweck haben sie? Wie kann man zur Laufzeit den dynamischen Typ, also die Klasse eines Objekts feststellen (drei Mglichkeiten)? Was unterscheidet Casts auf Referenztypen von solchen auf elementaren Typen? Warum soll man Casts vermeiden? Wodurch unterscheiden sich die Pseudovariablen this und super voneinander? Was macht man mit ihnen? Warum eignet sich hashCode nicht fr Vergleiche von Objekten? Welche Informationen soll in Kommentaren von Klassen, Interfaces, Methoden, Konstruktoren und Objektvariablen enthalten sein? Welche Arten von Zusicherungen in Form von Kommentaren kann man unterscheiden? Inwiefern knnen Namen und Kommentare altern? Was kann man dagegen tun? Wie knnen schlecht gewhlte Namen und Kommentare zu unntigem Programmcode fhren? Was zeichnet gut faktorisierte Programme aus? Erklren Sie die Begrie Klassenzusammenhalt und Objektkopplung. Wie hngen sie mit der Faktorisierung zusammen? Wie kann man den Klassenzusammenhalt und die Objektkopplung abschtzen? Wann sind notwendige nderungen von Kommentaren gefhrlich, wann eher harmlos? Wie speziziert man das Verhalten? Wann ist das Verhalten eines Untertyps mit dem eines Obertyps kompatibel?
241
3 Objektorientierte Konzepte
Wodurch entkoppelt Ersetzbarkeit Programmteile voneinander? Was bedeutet Typ ist stabil und welche Typen sind eher stabil? Wo soll man besonders auf stabile Typen achten? Warum ist es nicht sinnvoll, mglichst viel Programmcode von Oberklassen erben zu wollen?
242
4.1 Begrisbestimmungen
Die Suche nach geeigneten Algorithmen und Datenstrukturen steht im Mittelpunkt der Konstruktion von Programmen. Diese Ttigkeit erfordert viel Kreativitt und ist nicht automatisierbar. Dennoch lassen wir uns bei der Suche meist von bestimmten Strategien leiten, die schon in vielen Fllen erfolgreich waren. 4.1.1 Algorithmus Der Begri Algorithmus stammt vom im Mittelalter verwendeten lateinischen Begri algorismus, der die Kunst des Rechnens mit Zahlen bezeichnete. Tatschlich drfte algorismus aus der Verstmmlung des Beinamens eines arabischstmmigen Mathematikers entstanden sein. In jngerer Zeit hat sich die Bedeutung des Begris Algorithmus mit dem Aufkommen von Computern etwas verschoben. Im Mittelpunkt steht nicht mehr nur das Rechnen mit Zahlen, sondern ein Algorithmus ist ganz allgemein ein System von Regeln zur schrittweisen Umformung von Zeichenreihen. Dazu zhlen Anweisungen zur formalen Verarbeitung von Informationen. Es gibt zahlreiche Versuche, Algorithmus als modernen Begri zu denieren, jedoch keine allgemein akzeptierte Variante. Klar ist, dass es sich beim Algorithmus um eine eindeutige Handlungsvorschrift handeln muss, deren Befolgung auf die Lsung eines Problems abzielt. Schwierigkeiten bereitet dagegen die Forderung, dass nach endlich vielen Schritten tatschlich eine Lsung eines Problems berechnet ist. Aufgrund der Unentscheidbarkeit des Halteproblems (siehe Abschnitt 1.5.3) htten wir keine Mglichkeit zu
243
Listing 4.1: Unterschiedliche Implementierungen desselben Algorithmus int sumupLoop(int n) { int sum = 0; while (n > 0) { sum += n; n--; } return sum; } int sumupRec(int n) { if (n > 0) { return n + sumupRec(n - 1); } else { return 0; } }
entscheiden, ob eine bestimmte Handlungsvorschrift einen Algorithmus darstellt oder nicht. Aus praktischer Sicht spielen solche Spitzndigkeiten glcklicherweise keine Rolle. Wir wollen eher ein Gefhl dafr vermitteln, was ein Algorithmus ist bzw. wann zwei Algorithmen gleich sind. Betrachten wir als Beispiel die Berechnung der Summe aller Zahlen von 1 bis zu einer Obergrenze n, also 1 + 2 + + n. Dazu knnen wir folgenden einfachen Algorithmus verwenden: Solange n grer 0 ist, addieren wir n zur Summe, vermindern n um 1 und wiederholen den Vorgang mit der verminderten Zahl. Listing 4.1 zeigt zwei unterschiedliche Implementierungen dieses Algorithmus. Die Methode sumupLoop verwendet eine Schleife, um Wiederholungen auszudrcken, sumupRec verwendet dazu Rekursion. Trotz groer Unterschiede gehen beide Methoden nach demselben Algorithmus vor. An diesem Beispiel knnen wir erkennen, dass wir zwischen einem Algorithmus und den Implementierungen dieses Algorithmus unterscheiden mssen. In der Mathematik nennt man solche Summen Dreieckszahlen. Man +1) bekann sie mit der Gauschen Summenformel 1 + 2 + + n = n(n 2 rechnen. Die Methode triangleNum in Listing 4.2 lst dieselbe Aufgabe wie die beiden sumup-Varianten (Berechnen einer Dreieckszahl) mit Hilfe dieser Formel. Auch in den Fllen, die durch die Formel nicht abgedeckt sind (n 0), liefert triangleNum dasselbe Ergebnis. Die Algorithmen sind jedoch gnzlich verschieden. Dieses Beispiel zeigt, dass ein und dasselbe Problem durch unterschiedliche Algorithmen lsbar ist. Alle Methoden in den Listings 4.1 und 4.2 haben dieselben funktionalen Eigenschaften (siehe Abschnitt 1.6.4), liefern also dieselben Ergebnisse. In den nichtfunktionalen Eigenschaften unterscheiden sie sich jedoch.
244
4.1 Begrisbestimmungen
Listing 4.2: Ein anderer Algorithmus zur Lsung desselben Problems int triangleNum(int n) { if (n > 0) { return n * (n + 1) / 2; } else { return 0; } }
Die Methode triangleNum ist den beiden sumup-Varianten vorzuziehen: Der Ressourcenverbrauch, insbesondere die Laufzeit, ist vor allem fr grere Zahlen n deutlich kleiner, da nur ein einziger Ausdruck berechnet wird, whrend in den sumup-Varianten viele einzeln berechnete Summanden aufaddiert werden. Auerdem ist dieser eine Ausdruck fr jemanden, der die Formel kennt, einfach verstndlich. Die Formel ist im statischen Programmcode direkt ersichtlich, whrend wir uns fr ein Verstndnis der sumup-Varianten in den dynamischen Programmablauf hineindenken mssen. Andererseits gibt die ursprngliche Darstellung des Problems durch 1 + 2 + + n bereits einen dynamischen Ablauf vor, und es ist nicht oensichtlich, dass die Formel dasselbe Ergebnis berechnet. Wenn wir uns mit Algorithmen beschftigen, stellen wir uns solche Fragen wie in diesen Beispielen. Es geht darum, auf welche Weise ein Problem gelst werden kann und welche Eigenschaften entsprechende Algorithmen aufweisen. Wir wollen einen fr unseren Zweck gut geeigneten Algorithmus nden. Die Qualitt der Algorithmen ist im Groen und Ganzen nicht von Sprachen und Implementierungsvarianten abhngig: Nichtfunktionale Eigenschaften unterschiedlicher Algorithmen unterscheiden sich viel strker voneinander als unterschiedliche Implementierungen desselben Algorithmus. Wenn wir Algorithmen betrachten, abstrahieren wir ber Details von Programmiersprachen und Implementierungen. 4.1.2 Datenstruktur Eine Datenstruktur beschreibt, wie die Daten relativ zueinander angeordnet sind und wie auf die einzelnen Datenelemente (kurz Elemente ) zugegrien werden kann. Zur Charakterisierung einer Datenstruktur sind hauptschlich die Zugrisoperationen entscheidend. Hier ist eine kleine Auswahl aus der groen Zahl an sinnvollen Datenstrukturen:
245
Array: Die Elemente dieser einfachen Datenstruktur liegen nebeneinander im Speicher und sind ber einen Index direkt und ezient adressierbar, siehe Abschnitt 2.5. Die Anzahl der Elemente ist beschrnkt und wird sptestens bei der Erzeugung des Arrays festgelegt. Verkettete Liste: Jeder Listeneintrag verweist auf den nchsten Listeneintrag, siehe Abschnitt 4.2.1. Die Anzahl der Elemente ist dadurch nicht beschrnkt, und das Hinzufgen weiterer Elemente gestaltet sich einfach. Jedoch sind die Elemente nicht direkt ber einen Index zugreifbar, und die Suche nach bestimmten Elementen ist aufwendig. Binrer Baum: Jeder Eintrag verweist auf bis zu zwei weitere Eintrge, wobei eine bestimmte Sortierung der Eintrge eingehalten wird, siehe Abschnitt 4.2.3. Damit ergeben sich hnliche Eigenschaften wie bei der verketteten Liste, jedoch ist die Suche nach Elementen ezienter und das Hinzufgen neuer Elemente etwas aufwendiger. Hashtabelle: Jedes Element wird in einer Tabelle mit xer Gre an einem Index abgelegt, der sich aus dem Element selbst errechnet, siehe Abschnitt 4.3.3. Solange die Tabelle nur zu einem kleinen Teil gefllt ist, sind sowohl Hinzufgen als auch Suche recht ezient. Bei strkerer Fllung werden jedoch alle Zugrie inezient. Stack: Aus einem Stack knnen Elemente nur in der Reihenfolge gelesen und entfernt werden, die genau umgekehrt zur Reihenfolge des Einfgens ist. Einige Algorithmen brauchen diese Eigenschaft. In diesen Fllen sind Stacks sehr ezient, in anderen Fllen wenig sinnvoll. Stacks werden beispielsweise zur Implementierung von Programmiersprachen gebraucht, um geschachtelte Methodenaufrufe abzubilden. Bei der Konstruktion von Programmen entscheiden wir anhand der bentigten Eigenschaften der Zugrisoperationen, welche Datenstruktur zur Lsung unserer Aufgabe am ehesten passt. Hug kombinieren wir mehrere einfache Datenstrukturen zu einer greren, beispielsweise zu einem Array von verketteten Listen. Auf diese Weise knnen wir ber einen Index rasch auf die gewnschte Liste zugreifen und haben die Mglichkeit, ohne groen Aufwand beliebig viele Eintrge an einem Arrayindex abzulegen falls wir diese Eigenschaften brauchen. Durch eine gute Kenntnis einfacher Datenstrukturen lsst sich oft ohne groen Aufwand eine Datenstruktur mit allen bentigten Eigenschaften zusammensetzen.
246
4.1 Begrisbestimmungen
Listing 4.3: Ein durch ein Array implementierter Stack 1 public class IntStack { // Stack von ganzen Zahlen 2 private int[] elems; // Array enthlt Stackelemente 3 private int top = 0; // nchster freier Arrayindex 4 5 // Initialisierung mit Array der Gre max; max > 0 6 public IntStack(int max) { 7 elems = new int[max]; 8 } 9 10 // elem wird auf den Stack gelegt 11 // ArrayIndexOutOfBoundsException falls kein freier Platz 12 public void push(int elem) { 13 elems[top++] = elem; 14 } 15 16 // oberster Eintrag wird vom Stack geholt 17 // ArrayIndexOutOfBoundsException falls kein Eintrag vorhanden 18 public int pop() { 19 return (elems[--top]); 20 } 21 }
Die Klasse IntStack in Listing 4.3 implementiert einen Stack fr ganze Zahlen. Stackeintrge werden in einem Array abgelegt, dessen Gre im Konstruktor bestimmt wird. Bei ber- oder Unterschreitung der Arraygrenzen kommt es bei Arrayzugrien zu Laufzeitfehlern, um die man sich bei der Verwendung des Stacks kmmern muss siehe Kapitel 5. Betrachten wir einige allgemeine Eigenschaften von Datenstrukturen: hnlich wie bei Algorithmen mssen wir streng zwischen Datenstrukturen und Implementierungen von Datenstrukturen unterscheiden. Unter einer bestimmten Datenstruktur verstehen wir eine Ansammlung von Daten mit bestimmten Zugrisoperationen und Eigenschaften. Es gibt viele Mglichkeiten, ein und dieselbe Datenstruktur zu implementieren. Beispielsweise knnten wir einen Stack auch durch eine verkettete Liste statt einem Array implementieren. Datenstrukturen hngen nicht von Programmiersprachendetails ab. Man kann eine Datenstruktur zu Hilfe nehmen, um eine andere zu implementieren. Datenstrukturen sind ja vor allem durch ihre Zugrisoperationen bestimmt, und die lassen sich anpassen.
247
Zugrisoperationen werden durch Algorithmen festgelegt. Im StackBeispiel sind die Zugrisoperationen sehr einfach, sodass von den Algorithmen nicht viel zu erkennen ist. Aber wir werden noch andere Beispiele sehen, wo Zugrisoperationen durch komplexere Algorithmen beschrieben werden. Diese Algorithmen sind typisch fr bestimmte Datenstrukturen. Sie bestimmen deren Eigenschaften. Datenstrukturen und Algorithmen hngen stark voneinander ab. Algorithmen setzen bestimmte Datenstrukturen voraus, sodass Algorithmen nur zusammen mit den Datenstrukturen entwickelt werden knnen. Die Auswahl geeigneter Datenstrukturen ist in der Regel wichtiger als die der Algorithmen, weil ber ein und dieselbe Datenstruktur mehrere Algorithmen ausgefhrt werden mssen. Meist setzen wir ja dieselben Daten fr mehrere Zwecke ein. Beim Entwickeln von Algorithmen ist daher Vorsicht angebracht: Wenn man eine Datenstruktur zu sehr an einen Algorithmus anpasst, sodass sie nur fr diesen einen Algorithmus gut geeignet ist, dann ist es unter Umstnden schwierig, andere auf dieselbe Datenstruktur angewiesene Algorithmen zu entwickeln. 4.1.3 Lsungsstrategie Unter einer Strategie versteht man das langfristig orientierte Vorgehen in grundlegenden Fragen. Dabei sollen grundstzliche, fr den Erfolg entscheidende Ziele erreicht werden, sogenannte strategische Ziele. Im Zusammenhang mit der Programmkonstruktion ist vor allem ein strategisches Ziel von berragender Bedeutung: Einfachheit. Wir stehen oft einem hohen Grad an Komplexitt gegenber, einerseits wegen der unvermeidbaren inhaltlichen Komplexitt der zu lsenden Aufgaben, andererseits aber auch, weil sogar Programme zur Lsung einfacher Aufgaben dazu tendieren, im Laufe der Zeit immer umfangreicher, komplizierter und undurchschaubarer zu werden. Vor allem gegen Letzteres mssen wir ankmpfen. Nur wenn wir die Strukturen auf Dauer einfach halten, haben wir eine Chance, inhaltlich komplexe Aufgaben in den Gri zu bekommen. Das gilt auf allen Ebenen. Sowohl die Gesamtstruktur des Programms als auch einzelne Algorithmen und Datenstrukturen sollen einfach bleiben. Wir werden folgende Strategien betrachten, die alle eine Vereinfachung eines Systems bzw. von Algorithmen und Datenstrukturen zum Ziel haben: Teile-und-Herrsche. Lsungsanstze fr inhaltlich komplexe Aufgaben sind oft nur schwer zu nden. Um die Suche zu beschleunigen, neh-
248
4.1 Begrisbestimmungen
men wir manchmal an, dass bestimmte vereinfachende Eigenschaften erfllt sind und suchen nach einer Lsung unter diesen Annahmen. Dann sorgen wir dafr, dass die Annahmen erfllt werden. Top-Down. Wir gehen streng hierarchisch vor und konstruieren ein System anfangs nur auf einer sehr allgemeinen, abstrakten Ebene. Erst wenn wir diese Ebene im Detail verstanden haben, behandeln wir die einzelnen Teile des Systems auf gleiche Weise, bis wir ganz unten angelangt sind und alle Teile implementiert haben. Bottom-Up. Manchmal ist die genau entgegengesetzte Strategie sinnvoll: Wir beginnen damit, wahrscheinlich bentigte Programmteile auf unterster Ebene zu implementieren und arbeiten uns langsam in Richtung hherer, abstrakterer Ebenen vor. Schrittweise Verfeinerung. Wenn die Komplexitt vor allem durch den groen Umfang einer Aufgabe bestimmt ist, geht man oft so vor, dass man anfangs nur einen kleinen Teil der Aufgabe lst. Danach ergnzt man die Lsung Schritt fr Schritt um die fehlenden Teile. Verwendung vorgefertigter Teile. Fr hug wiederkehrende Aufgaben gibt es fertige Lsungen, die man direkt verwenden kann. So berzeugend das Streben nach Einfachheit als wichtigstem Ziel ist, so schwierig ist es in der Praxis manchmal, dieses Ziel im Auge zu behalten. Das hat mehrere Ursachen: Whrend man sich mit einem einzelnen Algorithmus in einem groen System beschftigt, konzentriert man sich allzuleicht nur auf die Efzienz dieses einen Algorithmus und bersieht die Zusammenhnge mit dem groen Ganzen. Sowohl die Einfachheit als auch die Ezienz des Gesamtsystems kann darunter leiden. Whrend des Programmierens besteht die Gefahr der falschen Einschtzung der Komplexitt. Man glaubt, eine einfache, eziente Lsung gefunden zu haben, aber andere Personen verstehen diese Lsung nicht, und nach einiger Zeit versteht man sie selbst nicht mehr. Diese Gefahr ist besonders gro, wenn es sich um eine trickreiche Lsung handelt, auf die man anfangs besonders stolz ist. Die oensichtlichsten Algorithmen und Datenstrukturen sind nicht immer die einfachsten und ezientesten. Hug ist es so wie in den
249
Beispielen in Abschnitt 4.1.1, dass einfachere und ezientere Algorithmen und Datenstrukturen mehr Wissen erfordern als komplexere. Daher kann Einfachheit den Entwicklungsaufwand erhhen. Die Vermeidung der ersten beiden Ursachen kostet viel berwindung. Man muss sich dazu durchringen, fr manche Teile der Aufgabe eine vermeintlich gute Lsung gegen eine weniger schne auszutauschen, um einem in diesem Augenblick unbestndig erscheinenden strategischen Ziel nher zu kommen. Langfristig zahlt es sich aber aus, bewhrten Strategien zu folgen und bertriebenes Ezienzdenken auf der Detailebene aufzugeben.
250
Listing 4.4: Verkettete Liste ganzer Zahlen 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public class IntList { private IntListNode head = null; // Liste ganzer Zahlen // Listenkopf = 1. Element
public void add(int elem) { // fge elem am Anfang ein head = new IntListNode(elem, head); } public boolean contains(int elem) { // elem in Liste? return head != null && [Link](elem); } public void remove(int elem) { // Lschen des ersten Vorkommens von elem aus der Liste // Liste bleibt unverndert, wenn elem nicht vorkommt if (head != null) { head = [Link](elem); // neuer Anfang nach Lschen } } } class IntListNode { // Listenknoten, verwendet von IntList private int elem; // das eigentliche Listenelement private IntListNode next; // nchster Knoten = Listenrest IntListNode(int elem, IntListNode next) { [Link] = elem; [Link] = next; } boolean contains(int e) { /*suche e in Restliste */ ... } IntListNode remove(int e) { /*lsche e aus Restliste*/ ... } }
Klasse, dieses enthlt ebenso ein Objekt, dieses wieder eines, und so weiter. Auf den ersten Blick scheint es so, als ob wir gar kein Objekt von IntListNode erzeugen knnten, da wir dafr schon ein existierendes Objekt derselben Klasse bentigen wrden. Es geht aber trotzdem, da wir in Java statt einem Objekt auch null verwenden knnen. Wir erzeugen also ein erstes Objekt von IntListNode, in der next den Wert null enthlt. Danach erzeugen wir ein Objekt, in dem next das zuerst erzeugte Objekt enthlt, und so weiter. Rekursive Datenstrukturen beschreiben also beliebig groe Datenmengen, aber keine unendlich groen, die im Speicher eines Computers ja niemals Platz haben wrden.
251
(a)
(b)
- 4 - 1 - 3 - 2 - 3
(c)
(d)
- 4 - 1 - 2 - 3
@
3 @ - 1 - 2 - 3
@ @
4 3 @ @
Abbildung 4.5 zeigt schematisch einige verkettete Listen. Ein Kreis entspricht einem Objekt von IntListNode, die Zahl im Kreis dem Inhalt der Variablen elem, und ein von einem Kreis ausgehender Pfeil dem Inhalt von next. Ohne ausgehenden Pfeil enthlt die Variable den Wert null. Auf Objekte von IntListNode greifen wir ber Objekte von IntList zu, die durch kleine Kstchen symbolisiert sind. Die Variable head in jedem Objekt von IntList enthlt den Listenkopf, das ist das Objekt von IntListNode, mit der die Liste beginnt. Ein von einem Kstchen ausgehender Pfeil zeigt auf den Inhalt dieser Variablen. Eine solche Form der Darstellung kennen wir aus der Mathematik und verwenden wir oft in der Informatik. Es handelt sich um einen gerichteten Graphen. Kreise und Kstchen stellen verschiedene Arten von Knoten des Graphen dar (daher der Name IntListNode), Pfeile die Kanten. Praktisch alle rekursiven Datenstrukturen lassen sich durch gerichtete Graphen veranschaulichen. Eigenschaften dieser Graphen entsprechen auch Eigenschaften der Datenstrukturen. Eine Liste wird immer durch einen zusammenhngenden (das heit, alle Knoten sind durch Kanten miteinander verbunden) gerichteten Graphen symbolisiert, in dem von jedem Knoten hchstens eine Kante ausgeht. In unserer speziellen Variante enthlt jede Liste genau ein Kstchen am Anfang, und jeder Kreis enthlt eine Zahl. Operationen auf einer Liste drfen die Listeneigenschaften nicht verletzen. Von IntList werden nur drei einfache Operationen untersttzt
252
siehe Listing 4.4 mit Methodenimplementierungen in Listing 4.6: add erzeugt einen neuen Listenknoten und fgt ihn ganz am Anfang ein, vor eventuell bereits vorhandenen Listenknoten. Der Graph (b) in Abbildung 4.5 wird beispielsweise durch Einfgen von 4 in die durch den Graphen (a) dargestellte Liste erzeugt. Die Reihenfolge des Einfgens bleibt erhalten. Liste (a) kann so entstanden sein: IntList x = new Intlist(); [Link](3); [Link](2); [Link](3); [Link](1); Die Zahl 3 wurde doppelt eingefgt, wobei die zuletzt eingefgte Zahl 3 zwecks Unterscheidbarkeit in Abbildung 4.5 durch 3 markiert ist. contains sucht eine Zahl in der Liste und gibt einen Wahrheitswert zurck, der besagt, ob die gesuchte Zahl mindestens einmal in der Liste enthalten ist. Der wesentliche Teil der Implementierung in Listing 4.6 wandert rekursiv ber die Listenknoten, bis der gesuchte Eintrag gefunden ist oder keine weiteren Knoten mehr vorhanden sind. Dabei kommen die Kurzschlussoperatoren && und || zum Tragen: Der rechte Teilausdruck wird nur ausgewertet, wenn die Auswertung des linken true (bei &&) bzw. false (bei ||) ergibt. remove lscht den ersten Knoten aus der Liste, der die als Argument bergebene Zahl enthlt, falls es einen solchen Knoten gibt. Diese in IntList implementierte Methode gibt kein Ergebnis zurck. Die entsprechende Methode in IntListNode (siehe Listing 4.6) liefert als Ergebnis den Rest der Liste nach dem Lschen. Das erleichtert die rekursive Implementierung: Wir brauchen nur Knoten fr Knoten ber die Liste zu wandern und, wenn wir den gesuchten Knoten gefunden haben, dessen Nachfolger zurckgeben; sonst kommt der Knoten selbst (also this) zurck. Anschlieend setzen wir in jedem besuchten Knoten die Variable next auf das Ergebnis des rekursiven Aufrufs. Entsprechend muss auch remove in IntList die Variable head auf das Ergebnis des Aufrufs von remove in IntListNode setzen. Die Liste (c) in Abbildung 4.5 zeigt, was passiert, wenn wir in der Liste (b) remove(3) aufrufen: Es folgen Aufrufe gleichnamiger Methoden in den ersten drei Knoten (4, 1 und 3 ), wobei der letzte Aufruf den Knoten mit 2 zurckgibt, der dann in der Variablen next des Knotens 1 abgelegt wird, womit 2 auf 1 folgt. Die anderen Aufrufe geben this zurck, wodurch die ersten Knoten unverndert bleiben. Der Knoten 3 ist somit aus der Liste entfernt, obwohl
253
Listing 4.6: Rekursives Suchen und Lschen auf verketteter Liste // Diese Methoden stehen in der Klasse IntListNode boolean contains(int e) { // suche e rekursiv in Restliste return elem == e || (next != null && [Link](e)); } IntListNode remove(int e) { // // if (elem == e) { // return next; // } else if (next != null) { // next = [Link](e); // } // return this; // lsche e aus Rest, Anfang = this Ergebnis ist Rest nach Lschen wenn this zu lschen ist: neuer Rest = next (ohne this) sonst bei weiteren Knoten: Lsche e aus briger Liste und next = neue brige Liste neue Restliste beginnt mit this
er noch immer existiert und den Knoten 2 enthlt. ber die Liste ist der Knoten 3 nicht mehr zugreifbar. Da er auch ber keinen anderen Weg zugreifbar ist, wird er vom Java-System irgendwann aus dem Speicher entfernt. Auf hnliche Weise entsteht der Graph (d) durch Lschen von 4 aus (c). Hier wird jedoch der erste Knoten gelscht, sodass head in IntList auf den Nachfolgeknoten 1 gesetzt wird.
4.2.2 Rekursion versus Iteration Am Beispiel der verketteten Liste knnen wir sehen, wie rekursive Datenstrukturen oft auf natrliche Weise rekursive Implementierungen der Zugrisoperationen ergeben. Im Gegensatz zu rekursiven Datenstrukturen sind rekursive Methoden immer durch nicht-rekursive Methoden ersetzbar, die statt der Rekursion eine Schleife (also Iteration) verwenden. Die Methoden in Listing 4.7 entsprechen denen in Listing 4.6, vermeiden jedoch Rekursion. Im direkten Vergleich ist sofort zu erkennen, dass die rekursiven Methoden krzer und einfacher sind als die nicht-rekursiven, obwohl sie dieselben Algorithmen implementieren. Analoge Beobachtungen machen wir oft. Trotzdem wird Rekursion von Personen mit wenig Programmiererfahrung hug als schwierig empfunden. Wir wollen die Unterschiede zwischen Rekursion und Iteration daher nher betrachten.
254
Listing 4.7: Iteratives Suchen und Lschen auf verketteter Liste ganzer Zahlen // Diese Methoden stehen in der Klasse IntListNode boolean contains(int e) { // suche e iterativ in Restliste IntListNode node = this; // der gerade durchsuchte Knoten do { // Schleife ber Knoten: if ([Link] == e) { // wenn gesuchte Zahl gefunden: return true; // beende erfolgreiche Suche } node = [Link]; // sonst suche in nchstem Knoten } while (node != null); // solange noch Knoten vorhanden return false; // e nicht in Liste vorhanden } IntListNode remove(int e) { // lsche e aus Liste (iterativ) if (elem == e) { // wenn 1. Element zu lschen: return next; // Ergebnis ist Rest (ohne this) } // sonst lsche e aus Restliste IntListNode node = this; // betrachte Nachfolger while ([Link] != null) { // solange Nachfolger da: if ([Link] == e) { // Nachfolger zu lschen: [Link] = [Link]; // hnge Nachfolger aus return this; // und beende Lschen } // (gleicher Anfang) node = [Link]; // sonst nchster Knoten } return this; // Listenanfang bleibt unverndert }
Die iterativen Methoden brauchen zustzliche lokale Variablen um in einer Schleife ber den Knoten der Liste stets zu wissen, welcher Knoten gerade betrachtet wird. In den rekursiven Methoden bernimmt this diese Aufgabe. Alleine schon durch diesen Unterschied sind iterative Methoden deutlich lnger. In contains kommt hinzu, dass wir statt ganz einfacher Kurzschlussoperatoren aufwendigere bedingte Anweisungen verwenden mssen. In remove wird der iterative Code aus zwei anderen Grnden lnger: Einerseits mssen wir das Lschen des ersten Knotens anders behandeln als das Lschen eines weiteren Knotens, da im ersten Fall remove in IntList einen Teil zu erledigen hat, whrend im zweiten Fall remove in IntListNode alleine dafr zustndig ist. Dazu brauchen wir eine Fallunterscheidung. Andererseits knnen wir im zweiten Fall die einfache Technik, durch die wir den neuen Listenrest als Ergebnis eines rekursiven Aufrufs bekommen, nicht anwenden. Stattdessen mssen wir in
255
der Schleife stets vorausblicken, um den fr das Lschen ntigen Vorgngerknoten nicht zu verlieren. Beispielsweise mssen wir die gesuchte Zahl mit [Link] vergleichen, nicht einfach nur mit [Link]. Das vergrert den Code, verschlechtert die Lesbarkeit und erhht die Anzahl der Speicherzugrie. Manchmal bentigt man sogar einen zustzlichen Stack, um Rekursion durch Iteration zu ersetzen (Beispiel in Abschnitt 4.5.4). Ein Nachteil rekursiver Varianten besteht in der hohen Anzahl an Methodenaufrufen. Jeder Aufruf kostet etwas Zeit und Speicherplatz. Damit wird oft begrndet, warum man iterative Varianten vorzieht. Diese Argumentation trit nur selten zu, da der zustzliche Ressourcenbedarf fr Aufrufe kaum ins Gewicht fllt, aber iterative Varianten durch komplizierteren Code oft einen hheren Ressourcenbedarf haben. In Abschnitt 4.3.1 werden wir die Zusatzkosten der Rekursion abzuschtzen lernen. Rekursive Methoden und rekursive Datenstrukturen haben viele Gemeinsamkeiten mit vollstndiger Induktion. Dieses mathematische Beweisverfahren beruht auf den natrlichen Zahlen. Wir beweisen, dass eine Aussage fr die Zahl 1 (oder 0) gilt. Wenn diese Aussage unter der Annahme, dass sie fr eine beliebige natrliche Zahl n gilt, auch fr n + 1 gilt, dann gilt sie tatschlich fr jede natrliche Zahl n. Als Beispiel wollen wir einen n n (n + 1) zeigen: Beweis fr die Gausche Formel i= 2 i=i
1
i=1=
1 (1 + 1) 2
Induktionsschritt: Unter der Annahme, dass die Formel fr n gilt, muss n+1 (n + 1) (n + 2) sie auch fr n +1 gelten. Wir mssen i= zeigen: 2 i=1 n+1 n n (n + 1) n (n + 1) + 2 (n + 1) i = ( i) + n + 1 = +n+1 = 2 2 i=1 i=1 Da die Formel fr den Induktionsanfang und den Induktionsschritt gilt, gilt sie fr jede natrliche Zahl n. Der Induktionsanfang spielt eine wichtige Rolle. Nur wenn es eine Basis gibt, auf die wir aufbauen knnen, ist auch der Induktionsschritt sinnvoll. Auch fr rekursive Datenstrukturen brauchen wir eine Basis. Beispielsweise knnen wir Listeneigenschaften folgendermaen sicherstellen:
256
Induktionsanfang: Die vom Konstruktor erzeugte leere Liste erfllt alle Listeneigenschaften. Das mssen wir berprfen. Induktionsschritt: Unter der Annahme, dass x eine Liste ist, die alle Listeneigenschaften erfllt, muss x auch nach Ausfhrung jeder beliebigen Listenoperation eine Liste sein und alle Listeneigenschaften erfllen. Auch das mssen wir fr jede Listenoperation berprfen. Unter diesen Bedingungen wissen wir, dass die Listeneigenschaften stets erfllt sind. Aufgrund der Einfachheit der Listeneigenschaften ist leicht zu sehen, dass sie erfllt sind: Von jedem Knoten kann immer nur eine Kante ausgehen, da keine weiteren Variablen dafr vorhanden sind. Nur die Bedingung, dass der Graph zusammenhngend ist, erfordert Achtsamkeit. Bei jeder nderung der Liste mssen wir sicherstellen, dass Listenteile nicht unabsichtlich verloren gehen. Ein gutes Verstndnis der vollstndigen Induktion ist sehr hilfreich im Umgang mit rekursiven und iterativen Methoden. Formal knnen wir ber vollstndige Induktion beweisen, dass nach Beendigung eines Aufrufs fr alle betrachteten Datenelemente bestimmte Eigenschaften erfllt sind. Beispielsweise soll remove eine Liste unverndert lassen, wenn das zu lschende Element nicht enthalten ist, und wenn ein solches Element vorhanden ist, soll die Liste um genau einen Knoten krzer werden. Mit etwas Erfahrung stellen wir beim Programmieren entsprechende berlegungen an, auch ohne Notwendigkeit fr einen formalen Beweis. Entsprechende Beweise fr remove knnten etwa so aussehen: Induktionsanfang: Wenn die Liste leer ist, sind die Bedingungen trivialerweise erfllt, da remove in IntListNode gar nicht aufgerufen wird. Zur berprfung aller Teile der Bedingung mssen wir einen etwas komplexeren Anfang whlen: Die Bedingungen sind auch erfllt, wenn die Liste genau ein Element enthlt. Das sieht man durch Betrachtung aller mglichen Flle, die dabei auftreten knnen. Induktionsschritt: Unter der Annahme, dass die Bedingungen fr eine Liste mit n Elementen (wobei n 1) erfllt ist, mssen sie auch fr eine Liste mit n + 1 Elementen erfllt sein. Auch das ist durch Betrachtung aller mglicher auftretender Flle leicht zu sehen. Fr Aufrufe rekursiver Methoden und Schleifendurchlufe in iterativen Methoden mssen wir dieselben berprfungen anstellen. Das verdeutlicht die prinzipielle bereinstimmung zwischen rekursiven und iterativen Methoden, abgesehen von Details.
257
@ R @
@ R @
?
@ R @
?
@ R @
?
?
4.2.3 Binrer Baum Die Suche in einer verketteten Liste kann lange dauern, vor allem wenn das gesuchte Element nicht vorhanden ist. Dann mssen alle Listenknoten durchsucht werden. Bume knnen diesen Aufwand reduzieren: Jeder Baumknoten kann mehrere Nachfolgeknoten haben, in binren Bumen bis zu zwei. Entsprechend dem Wert des gesuchten Eintrags wird die Suche mit dem einen oder dem anderen Nachfolgeknoten fortgesetzt. So muss in den meisten Fllen nur eine kleine Auswahl an Knoten durchsucht werden um festzustellen, ob ein Wert enthalten ist oder nicht. Abbildung 4.8 zeigt eine schematische Darstellung einiger binrer Bume. Traditionell wachsen Bume in der Informatik von oben nach unten. Den obersten durch einen Kreis dargestellten Knoten nennt man die Wurzel des Baums. Knoten, die keine Nachfolgeknoten haben, nennt man Bltter. Im Baum (a) enthlt die Wurzel den Wert 3 und die Bltter die Werte 1 und 3 . Jeder Knoten im Baum ist Wurzel eines Teilbaums. Beispielsweise ist der Knoten 2 im Baum (a) Wurzel des Teilbaums bestehend aus den Knoten 2 und 1. Dieser Teilbaum ist der linke Teilbaum unter dem Knoten 3, whrend der rechte Teilbaum nur aus dem Knoten 3 besteht. Ein nicht-leerer binrer Baum hat folgende Eigenschaften: Er ist ein zusammenhngender gerichteter Graph, in dem jeder kreisfrmige Knoten mit einem Label (in unserem Beispiel einer Zahl) versehen ist und genau eine eingehende sowie hchstens je eine linke und rechte ausgehende Kante hat. Dabei haben wir auch die vom Kstchen kommende Kante gezhlt;
258
Listing 4.9: Binrer Baum ganzer Zahlen 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 public class IntTree { private IntTreeNode root = null; // binrer Baum // Wurzel des Baums
public void add(int e) { // fge e in Baum ein if (root == null) { // wenn Baum noch leer: root = new IntTreeNode(e); // Wurzel = 1. Knoten } else { // wenn Baum nicht leer: [Link](e); // fge in Wurzel ein } } public boolean contains(int elem) { // ist elem im Baum? return root != null && [Link](elem); } public void remove(int elem) { // lsche ein elem if (root != null) { // wenn Baum nicht leer: root = [Link](elem); // lsche aus Wurzel } } } class IntTreeNode { private int elem; private IntTreeNode left = null; private IntTreeNode right = null; IntTreeNode (int e) { elem = e; } void add(int e) { /* fge e in Baum ein */ ... } boolean contains(int e) { /* suche e im Baum */ ... } IntTreeNode remove(int e) { /* lsche e aus Baum */ ... } } // // // // Knoten im Baum eigentliches Element linker Teilbaum rechter Teilbaum
wenn wir diese Kante ignorieren, hat die Wurzel keine eingehende Kante. Fr den binren Baum und jeden seiner Teilbume gilt, dass die Wurzel des Baums bzw. Teilbaums eindeutig bestimmt ist, jeder Knoten im linken Teilbaum unter dieser Wurzel ein Label hat, das kleiner dem Label der Wurzel ist (z.B. sind 1 und 2 kleiner 3), und jeder Knoten im rechten Teilbaum unter dieser Wurzel ein Label grer oder gleich dem Label der Wurzel hat.
259
Listing 4.10: Rekursives Einfgen in einen binren Baum void add(int e) { // Klasse IntTreeNode; fge e in Baum ein if (e < elem) { // wenn e in linken Teilbaum soll: if (left != null) { // wenn linker Teilbaum existiert: [Link](e); // fge e in linken Teilbaum ein } else { // wenn kein linker Teilbaum: left = new IntTreeNode(e); // erzeuge neuen Teilbaum } } else /* e >= elem */ { // e soll in rechten Teilbaum if (right != null) { // wenn rechter Teilbaum da: [Link](e); // fge e rechts ein } else { // kein rechter Teilbaum: right = new IntTreeNode(e); // erzeuge neuen Teilbaum } } }
Listing 4.11: Iteratives Einfgen in einen binren Baum void add(int e) { // Klasse IntTreeNode; fge e in Baum ein IntTreeNode node = this; // betrachteter Knoten while(true) { // endlos wiederholt: if (e < [Link]) { // e soll nach links if ([Link] != null) { // wenn Teilbaum da: node = [Link]; // weiter mit Teilbaum } else { // kein Teilbaum da: [Link] = new IntTreeNode(e); // neuer Teilbaum return; } } else /* e >= [Link] */ { // e soll nach rechts if ([Link] != null) { // wenn Teilbaum da: node = [Link]; // weiter mit Teilbaum } else { // kein Teilbaum da: [Link] = new IntTreeNode(e); // neuer Teilbaum return; } } } }
Listing 4.9 zeigt Teile der Implementierung eines binren Baums ber ganzen Zahlen. Wie bei der Liste bentigen wir zwei Klassen, eine fr die externe Darstellung und eine fr Baumknoten. Es werden auch in etwa
260
Listing 4.12: Rekursive Suche in einem binren Baum boolean contains(int e) { // IntTreeNode; suche e im Baum if (e < elem) { // e vielleicht im linken Teilbaum return left != null && [Link](e); } else if (e == elem) { // e gefunden return true; } else /* e > elem */ { // e vielleicht im rechten Teilbaum return right != null && [Link](e); } }
Listing 4.13: Iterative Suche in einem binren Baum boolean contains(int e) { // IntTreeNode; suche e im Baum IntTreeNode node = this; // betrachteter Knoten do { // wiederhole: if (e < [Link]) { // wenn e vielleicht links: node = [Link]; // links weiter } else if (e == [Link]) { // wenn e gefunden: return true; // Suche erfolgreich } else /* e > [Link] */ { // wenn e eventuell rechts: node = [Link]; // rechts weiter } } while (node != null); // solange Knoten vorhanden return false; // e nicht gefunden }
dieselben Listenoperationen untersttzt: Einfgen, Suchen und Lschen. In den Implementierungen dieser Operationen mssen jedoch viel mehr Fallunterscheidungen getroen werden als in Listen. Listing 4.10 zeigt eine rekursive Implementierung der Einfgeoperation add in IntTreeNode und Listing 4.11 eine entsprechende iterative Implementierung. Ein neuer Knoten kann nur als Blatt in den Baum eingehngt werden. Wir suchen eine geeignete Stelle, indem wir an der Wurzel beginnend entsprechend dem Label im linken oder rechten Teilbaum weitersuchen, bis wir eine freie Stelle gefunden haben. Der Baum (b) in Abbildung 4.8 entsteht durch Einfgen von 4 in den Baum (a). Da 4 grer 3 ist, wird bei der Suche stets der rechte Teilbaum gewhlt.
261
Listing 4.14: Rekursive Suche mit Lschen in einem binren Baum IntTreeNode remove(int e) { // lsche ein e aus Baum // Ergebnis: Wurzel nach dem Lschen if (e < elem) { // e vielleicht im linken Teilbaum: if (left != null) { // falls es linken Teilbaum gibt: left = [Link](e); // neuer Teilbaum nach Lschen } } else if (e == elem) { // this ist zu lschen: if (right == null) { // falls kein rechter Teilbaum: return left; // Ergebnis ist linker Teilbaum } if (left != null) { // falls beide Teilbume vorhanden [Link](left); fge linken Teilb. rechts ein } // jetzt kein linker Teilbaum return right; // Ergebnis ist rechter Teilbaum } else /* e > elem */ { // e vielleicht im rechten Teilbaum: if (right != null) { // falls es rechten Teilbaum gibt: right = [Link](e); // Teilbaum ev. verndert } } return this; // Wurzel des Baums unverndert
} private void addTree(IntTreeNode t) { // fge Baum links ein if (left != null) { // wenn es linken Teilbaum gibt: [Link](t); // fge in linken Teilbaum ein } else { // Knoten am weitesten links: left = t; // hier soll der Baum hin } }
Man sieht deutlich, dass bei der Suche nach einem freien Platz nur ein Teil des Baums betrachtet werden muss. Je mehr Knoten ein Baum hat, desto kleiner ist der Anteil der Knoten, der blicherweise zu betrachten ist. Der Baum (a) knnte dadurch entstanden sein, dass zuerst 3 (die Wurzel) eingefgt wurde, dann 2, dann 3 und schlielich 1. Die Reihenfolge knnte aber auch 3, 3, 2, 1 oder 3, 2, 1, 3 gewesen sein. Im Gegensatz zur verketteten Liste ist die Reihenfolge der Einfgungen in einen binren Baum nicht erkennbar. Die Listings 4.12 und 4.13 zeigen Implementierungen der Suche im binren Baum. Die Suche nach einem bestimmten Knoten hnelt der Suche
262
Listing 4.15: Iterative Suche mit Lschen in einem binren Baum IntTreeNode remove(int e) { // lsche ein e aus Baum IntTreeNode node = this; // aktuell betrachteter Knoten IntTreeNode last = null; // zuletzt betrachteter knoten do { // wiederhole: if (e < [Link]) { // e vielleicht links: last = node; // merke Knoten node = [Link]; // gehe links weiter } else if (e == [Link]) { // e gefunden, node lschen: IntTreeNode subst; // subst ersetzt node if ([Link] == null) { // kein rechter Teilbaum: subst = [Link]; // linker Zw. statt node } else { // rechter Teilbaum da: subst = [Link]; // rechter statt node if ([Link] != null) { // beide Zweige da: IntTreeNode r = [Link]; while ([Link] != null) { // suche Knoten r = [Link]; // ganz links } // und fge dort den [Link] = [Link]; // linken Zweig an } } // (jetzt node ersetzen) if (last == null) { // Wurzel ist zu ersetzen: return subst; // neue Wurzel } else if ([Link] == node) { [Link] = subst; // linker Vorgngerzweig } else /* [Link] == node */ { [Link] = subst; // rechter Vorgngerzweig } return this; // Wurzel unverndert } else /* e > [Link] */ { // e vielleicht rechts: last = node; // merke Knoten node = [Link]; // gehe rechts weiter } } while (node != null); // solange es Knoten gibt return this; // Wurzel unverndert }
nach einem freien Platz beim Einfgen. Da nur ein Teil des Baums durchsucht werden muss, ist die Suche im binren Baum meist deutlich ezienter als die Suche in einer Liste. Es gibt aber auch entartete Bume wie (d) in Abbildung 4.8, wo der Baum die Form einer Liste hat. In solchen (bei groen Bumen sehr unwahrscheinlichen) Fllen dauert die Suche wegen zustzlicher Grenvergleiche sogar lnger als in einer Liste.
263
Das Lschen eines Knotens aus einem binren Baum ist eine recht aufwendige Operation, wie man an den Implementierungen in den Listings 4.14 und 4.15 erkennen kann. Am Anfang steht wiederum die Suche nach dem zu lschenden Knoten. Dieser kann zwei Teilbume haben, die nach dem Lschen brig bleiben. Der Vorgngerknoten kann aber nur einen Teilbaum aufnehmen. Beispielsweise passiert das, wenn wir aus dem Baum (b) in Abbildung 4.8 den Wurzelknoten 3 lschen wollen: brig bleibt ein Teilbaum mit den Knoten 2 und 1 und einer mit den Knoten 3 und 4, aber in der Variablen root in IntTree ndet nur ein Baum Platz. Es gibt mehrere Mglichkeiten um mit diesem Problem umzugehen. Wir whlen eine einfach implementierbare Lsung: Wenn zwei Teilbume brig bleiben, fgen wir den linken Teilbaum an der so weit links wie mglich stehenden freien Stelle in den rechten Teilbaum ein; im Code in Listing 4.14 verwenden wir dafr eine eigene private Methode, in Listing 4.15 ist das Verschieben des Teilbaums in die Methode remove integriert. Im Beispiel wird der Teilbaum mit den Knoten 2 und 1 dadurch zum linken Teilbaum des Knotens 3 . brig ist danach nur mehr ein Teilbaum, und der wird an die Stelle des gelschten Knotens gesetzt. Baum (c) in Abbildung 4.8 zeigt das Ergebnis der Lschoperation ohne den gelschten Knoten. Wenn der zu lschende Knoten keinen oder nur einen Teilbaum hat, ist die Lschoperation einfacher. Der entartete Baum (d) entsteht beispielsweise durch Lschen des Knotens 4 aus dem Baum (c), ohne dass dadurch nderungen der Baumstruktur notwendig wren.
264
4.3.1 Abschtzung algorithmischer Kosten Kostenabschtzungen liefern keine genauen Werte, sondern nur ganz grobe Anhaltspunkte fr die tatschlichen Kosten. Diese Grundregel mssen wir immer im Auge behalten, wenn wir Kosten abschtzen. Es kommt nicht darauf an, ob wir eine Aufgabe mit 10, 100 oder 1000 Anweisungen lsen und dafr 5, 50, oder 500 Variablen brauchen. Solche Unterschiede sind vernachlssigbar klein, auch wenn es sich dabei um Vielfache handelt. Einige Implementierungen brauchen mehr Anweisungen als andere, einige Computer fhren in derselben Zeiteinheit mehr Anweisungen aus als andere, und ein Compiler spart durch Optimierungen mehr Zeit ein als ein anderer. Fr den Vergleich von Algorithmen spielen diese Unterschiede keine Rolle. Andererseits kommt es darauf an, ob eine Operation mit insgesamt 10 Anweisungen gelst wird, oder mit 10 Anweisungen pro Datenelement in einer Datenstruktur. Wenn die Datenstruktur Millionen oder Milliarden von Datenelementen enthlt, werden aus den 10 Anweisungen schnell zig Millionen oder Milliarden. Das ist nicht vernachlssigbar. Die Kosten fr das Einfgen einer Zahl in eine verkettete Liste wie in Listing 4.4 hngen nicht von der Anzahl der Elemente in der Liste ab. Man sagt, die Kosten fr das Einfgen sind konstant, wobei wir die Anzahl der dafr ntigen Anweisungen und Variablen gar nicht zhlen. Man sagt auch, die Kosten sind von der Ordnung 1, formal durch O(1) bezeichnet. Das Suchen eines Elements in der verketteten Liste verlangt dagegen, dass wir die Liste vom ersten Element bis zum gesuchten Element oder bis zum Ende durchwandern. Die Kosten hngen von der Anzahl der Elemente in der Liste und der Position ab, an der sich das gesuchte Element bendet. Im schlechtesten Fall muss die ganze Liste durchwandert werden. Wenn die Liste n Elemente enthlt, ist der zeitliche Aufwand im schlechtesten Fall von der Ordnung n oder formal O(n). Man sagt, die Kosten sind linear zur Anzahl der Listenelemente. Durchschnittlich mssen wir n/2 Knoten betrachten. Aber 1/2 ist nur ein konstanter Faktor, den wir genauso wie die Anzahl der Anweisungen pro betrachtetem Knoten ignorieren. Daher verursacht die Suche auch im Durchschnitt zeitliche Kosten von O(n). Zustzlich haben wir noch einen konstanten Aufwand zum Starten der Suche in IntList. Niedrigere (z.B. konstante) Kosten fallen aber im Vergleich zu hheren (z.B. linearen) Kosten nicht ins Gewicht und knnen vernachlssigt werden. Man sagt, die hheren Kosten dominieren. Ein Aufwand O(n) ergibt zusammen mit einem Aufwand O(1) wieder nur O(n). Auch das Lschen eines Elements aus der Liste wird von der Suche nach dem zu
265
lschenden Element dominiert, sodass wir dafr sowohl im Durchschnitt als auch im schlechtesten Fall lineare Kosten haben. Beim Einfgen eines Elements in einen binren Baum braucht nur ein Teil der vorhandenen Knoten betrachtet werden, bis wir die passende freie Stelle gefunden haben. Durch mathematische Analysen, auf die wir hier nicht nher eingehen, kann man zeigen, dass die zeitlichen Kosten dafr durchschnittlich logarithmisch zur Anzahl n der Knoten im Baum sind, also formal von O(log(n)). Diese Kosten liegen zwischen O(1) und O(n). Allerdings knnen Bume auch zu Listen entartet sein (so wie Baum (d) in Abbildung 4.8), sodass die Kosten im schlechtesten Fall so hoch sein knnen wie fr die Suche in der verketteten Liste, also linear zur Anzahl der Knoten im Baum. Die Kosten fr das Suchen im bzw. Lschen aus dem binren Baum sind auch so hoch wie fr das Einfgen, nmlich durchschnittlich O(log(n)) und im schlechtesten Fall O(n). Das ist so, weil fr alle diese Zugrisoperationen die Suche nach einer bestimmten Stelle im Baum dominiert. In manchen Varianten von Bumen (z.B. sogenannten AVL-Bumen) wird beim Einfgen und Lschen zustzlicher Aufwand betrieben, um das Entarten der Bume zu vermeiden und dadurch die Kosten auch im schlechtesten Fall auf O(log(n)) zu halten. Konstante, logarithmische und lineare Kosten gelten in der Regel als recht niedrig. Viele Algorithmen haben quadratische (O(n2 )), kubische (O(n3 )) oder noch hhere Kosten. Verdoppelt man n, dann bleibt der Aufwand bei konstanten Kosten gleich, erhht sich bei logarithmischen Kosten leicht, verdoppelt sich bei linearen Kosten, vervierfacht sich bei quadratischen Kosten, verachtfacht sich bei kubischen Kosten, und so weiter. Aber alle polynomialen Kosten, das sind Kosten der Form O(nk ) fr alle beliebigen Zahlen k sind noch klein im Vergleich zu exponentiellen Kosten O(2n ), bei denen schon eine Erhhung von n um eins zur Verdopplung des Aufwands fhrt. Algorithmen mit exponentiellen Kosten werden hug als beinahe unbrauchbar angesehen, da damit nur sehr kleine Probleme (mit sehr kleinem n) in vertretbarer Zeit und mit dem zur Verfgung stehenden Speicher lsbar sind. Beispielsweise sind 232 = [Link] (etwa vier Milliarden) Operationen fr manche Aufgaben gerade noch in vertretbarer Zeit ausfhrbar und ebensoviele Speicherzellen in neueren Computern gerade noch vorhanden, aber 264 = [Link].709.551.616 Operationen bzw. Speicherzellen ziemlich sicher nicht mehr, obwohl 32 und 64 nur kleine Zahlen sind. Auch exponentielle Kosten sind noch lange nicht die hchsten vorstellbaren Kosten. Hhere als exponentielle Kosten sind zwar von theoretischer, aber kaum von praktischer Bedeutung.
266
Es ist ziemlich oensichtlich, dass verkettete Listen und binre Bume einen zur Anzahl der Knoten linearen Speicherbedarf haben. Der Speicherbedarf fr einzelne Operationen ist dagegen fr die iterativen Varianten konstant. In den rekursiven Varianten sind jedoch zustzliche Kosten versteckt: Rekursive Ausfhrungen von Methoden existieren gleichzeitig und brauchen daher auch gleichzeitig Speicherplatz. Bei der rekursiven Suche im bzw. dem rekursiven Lschen aus einer verketteten Liste mit n Knoten haben wir gleichzeitig bis zu n rekursive Ausfhrungen, im Schnitt n/2. Aus diesem Grund sind auch die Speicherkosten linear, nicht nur die zeitlichen Kosten. Die zeitlichen Kosten der rekursiven Varianten sind dagegen von derselben Ordnung wie die der iterativen Varianten. Entsprechend sind die Speicherkosten fr die rekursiven Varianten des Einfgens, der Suche und des Lschens im binren Baum im Durchschnitt logarithmisch und im schlechtesten Fall linear, genauso wie die zeitlichen Kosten, fr die iterativen Varianten aber konstant. Verkompliziert wird die Kostenabschtzung dadurch, dass ein guter Compiler fr manche Arten der Rekursion den zustzlichen Platzbedarf eliminieren kann, sodass er nicht grer ist als der fr entsprechende iterative Varianten. Kostenabschtzungen liefern gute Vorhersagen fr die Skalierbarkeit: Ein Algorithmus skaliert gut, wenn er fr groe Datenmengen ebenso einsetzbar ist wie fr kleine. Er skaliert schlecht, wenn er zwar fr kleine Datenmengen gut einsetzbar ist, aber nicht fr groe. Wenn wir wissen, wie gro die Datenmenge sein wird, auf die wir einen Algorithmus anwenden, bringt uns die Kostenabschtzung nichts; unter dieser Voraussetzung hat jeder Algorithmus konstante Kosten. Wie gro der konstante Aufwand ist, knnen wir durch Messen der Laufzeit bzw. des Speicherverbrauchs feststellen. Oft sind schlecht skalierende Algorithmen bei bekannter Datengre ezienter als gut skalierende. Ein huger Fehler besteht darin, zum Vergleich von Algorithmen Messungen mit einer bestimmten Datenmenge vorzunehmen und den dabei ezientesten Algorithmus auch auf grere Datenmengen anzuwenden; die damit getroene Wahl ist wahrscheinlich schlecht, da die Skalierbarkeit auer Acht gelassen wurde. Der Ressourcenbedarf soll ausgewogen sein. Es bringt nichts, wenn der Speicherverbrauch klein bleibt, aber der zeitliche Aufwand zu gro wird, oder umgekehrt. Ein ezienter Programmteil bringt auch nichts, wenn die Inezienz anderer Teile nur kleine Datenmengen zulsst. Beispielsweise haben wir unter diesen Gesichtspunkten wahrscheinlich keinen Vorteil dadurch, dass wir rekursive durch iterative Varianten der Methoden ersetzen, da ohnehin andere Kosten dominieren.
267
Nicht nur fr Algorithmen, sondern auch fr Probleme kann man Aufwandsabschtzungen machen. Die Kosten eines Problems entsprechen den Kosten des ezientesten Algorithmus, der das Problem lsen kann. 4.3.2 Kosten im Zusammenhang Bei der Konstruktion von Programmen haben wir viel Gestaltungsfreiheit. Wir knnen entscheiden, welche Operationen bentigt werden und wann welche Operationen auszufhren sind. Diese Freiheit macht es oft schwer, geeignete Datenstrukturen und Algorithmen zu whlen, da ein direkter Vergleich bei stark unterschiedlichen Anstzen kaum mglich ist. Als Beispiel betrachten wir die sortierte Ausgabe aller Zahlen in einer Datenstruktur. Listing 4.16 zeigt Methoden, die als Teile von IntList und IntListNode den Inhalt einer Liste ausgeben bzw. sortieren. Um eine sortierte Ausgabe zu erhalten, mssen wir die Liste zuerst sortieren und dann die sortierte Liste ausgeben. Die Methoden in Listing 4.17, hinzugefgt zu IntTree und IntTreeNode, erlauben dagegen die sortierte Ausgabe, ohne vorher eine Methode zum Sortieren aufrufen zu mssen. Der zeitliche Aufwand fr sort in Listing 4.16 ist quadratisch: Wir laufen so oft vom Anfang der Liste bis zum Ende und vertauschen dabei die Elemente von je zwei benachbarten Knoten, wenn diese in der falschen Reihenfolge stehen, bis ein Durchlauf durch die Liste keine nderung mehr bewirkt. Dieses Sortierverfahren nennt sich Bubblesort. Ein Listendurchlauf verursacht Kosten von O(n). Im schlechtesten Fall muss das letzte Listenelement ganz an den Anfang wandern. Dafr sind n Listendurchlufe notwendig, was Kosten von O(n2 ) ergibt. Im Schnitt werden halb soviele Durchlufe gebraucht, das ist ebenso ein quadratischer Aufwand. Der Platzbedarf fr das Sortieren ist dagegen konstant. Der zeitliche Aufwand fr print ist sowohl in der Liste als auch im Baum linear, da dabei jeweils jedes Element genau einmal besucht wird. Der Platzbedarf fr print ist in der Liste konstant. Im Baum ist er jedoch wegen der rekursiven Implementierung im Durchschnitt logarithmisch (weil die Anzahl der berlappenden rekursiven Aufrufe von der Tiefe des Baums abhngt) und im schlechtesten Fall (entarteter Baum) linear. Wir wollen nun ermitteln, wie hoch der Aufwand insgesamt ist, wenn wir eine Datenstruktur mit n Elementen aufbauen und die Elemente anschlieend sortiert ausgeben. Die Kosten fr das Einfgen eines Elementes
268
Listing 4.16: Ausgeben und Sortieren einer Liste // Klasse IntList; gib alle Elemente der Liste aus public void print() { // Ausgabe in Reihenfolge der Elemente if (head != null) { [Link](); } [Link]("fertig"); } // Klasse IntList; sortiere alle Elemente der Liste aufsteigend public void sort() { if (head != null) { [Link](); } } // Klasse IntListNode; hier passiert die eigentliche Ausgabe void print() { // Ausgabe in Reihenfolge der Listenelemente IntListNode p = this; do { [Link]([Link]); p = [Link]; } while (p != null); } // Klasse IntListNode; hier wird tatschlich sortiert void sort() { // Algorithmus ist "Bubblesort" boolean changed; // haben sich nderungen ergeben? do { changed = false; IntListNode s = this; // smaller; sollte kleiner sein IntListNode l = next; // larger; sollte grer sein while (l != null) { if ([Link] < [Link]) { // Elemente zu vertauschen int i = [Link]; [Link] = [Link]; [Link] = i; changed = true; } s = l; // weiter mit nchstem s,l-Paar l = [Link]; } } while (changed); // solange etwas gendert wurde }
in die verkettete Liste sind konstant, die fr das Einfgen von n Elementen daher sowohl hinsichtlich der Zeit als auch des Platzes O(n). Anschlieend
269
Listing 4.17: Sortierte Ausgabe aller Elemente in binrem Baum // Klasse IntTree; gib alle Elemente aufsteigend sortiert aus public void print() { if (root != null) { [Link](); } [Link]("fertig"); } // Klasse IntTreeNode; hier passiert die eigentliche Arbeit void print() { if (left != null) { [Link](); // gib alle kleineren Elemente aus, } [Link](elem); // dann das eigene if (right != null) { [Link](); // und schlielich alle greren } }
sortieren wir die Liste mit einem zeitlichen Aufwand von O(n2 ) und geben die Listenelemente mit einem zeitlichen Aufwand von O(n) aus. Da es sich dabei nur um eine konstante Zahl von Schritten handelt, ergibt sich der Gesamtaufwand aus dem Schritt mit den dominierenden Kosten, das ist das Sortieren. Der gesamte zeitliche Aufwand bei Verwendung der Liste ist daher O(n2 ). Weil die Operationen nur konstanten Platzbedarf haben, dominiert die Liste selbst den Platzbedarf mit O(n). Wegen seiner Einfachheit haben wir in Listing 4.16 ein recht inezientes Sortierverfahren gewhlt. Ein ezienteres Verfahrens, z.B. Quicksort (siehe Abschnitt 4.4.2) mit einem durchschnittlichen Aufwand von O(n log(n)) und O(n2 ) im schlechtesten Fall, kann den gesamten zeitlichen Aufwand im Schnitt auf O(n log(n)) reduzieren. Auch mit dem ezientesten Sortierverfahren wird das Sortieren noch immer dominieren. Verwenden wir statt der Liste einen Baum, knnen wir auf das Sortieren verzichten und brauchen nur n Elemente einzufgen und anschlieend auszugeben. Der zeitliche Aufwand fr das Einfgen eines Elements betrgt im Durchschnitt O(log(n)) und im schlechtesten Fall O(n). Das Einfgen von n Elementen hat den n-fachen zeitlichen Aufwand, das ist im Durchschnitt O(n log(n)) und im schlechtesten Fall O(n2 ). Das ist auch der Gesamtaufwand, da er gegenber dem linearen Aufwand fr das Ausge-
270
ben dominiert. Hinsichtlich des Platzbedarfs dominiert auch bei Verwendung des Baums die Datenstruktur selbst mit O(n), da fr Operationen gleichzeitig nie mehr als der Platz fr eine Einfgeoperation bzw. fr die Ausgabe bentigt wird. Es spielt in der Gesamtbetrachtung also keine Rolle, ob wir fr das Einfgen die iterative oder rekursive Variante whlen. Wie das Beispiel zeigt, fhrt eine Aufwandsabschtzung nicht selten zu unerwarteten Ergebnissen. Obwohl wir bei Verwendung der Liste einen zustzlichen aufwendigen Sortierschritt bentigen, ist der Aufwand genau gleich wie bei Verwendung des Baums im schlechtesten Fall. Wenn wir Bubblesort durch Quicksort ersetzen, kommt die Lsung mit der Liste auch im Durchschnitt auf genau dieselben Kosten wie die Lsung mit dem Baum. Durch Verwendung anderer Sortierverfahren und den Einsatz von AVL-Bumen statt einfacher Bume kommen beide Lsungsvarianten auch im schlechtesten Fall auf gleiche Kosten von O(n log(n)). Bei Betrachtung der beiden Lsungsvarianten in noch grerem Zusammenhang ergeben sich groe Unterschiede. Wenn wir die Elemente beispielsweise auch in der Reihenfolge ausgeben mssen, in der sie eingefgt wurden, kommt nur die Lsung mit der Liste in Frage, da Bume die Reihenfolge nicht erhalten knnen. Wenn hingegen hug Elemente in der Datenstruktur gesucht werden mssen, ist die Lsung mit dem Baum deutlich ezienter. Beim Konstruieren von Programmen mssen wir also stets vorausblicken und berlegen, wie wir in Zukunft auf unsere Datenstrukturen zugreifen mssen. Um die beste Datenstruktur zu nden braucht es viel Erfahrung und auch eine groe Portion Glck. 4.3.3 Zufall und Wahrscheinlichkeit Der Aufwand einer Operation ist hug vom Zufall abhngig. Beispielsweise ist die Suche in einer verketteten Liste sehr ezient, wenn das gesuchte Element gleich am Anfang steht, aber inezient, wenn es erst am Ende zu nden ist. Das macht Aufwandsabschtzungen schwierig. Ein Hilfsmittel zur Untersuchung des zufallsbedingten Aufwands ist die Betrachtung einer groen Anzahl von Fllen. Statt einer Suche betrachten wir vielfach wiederholte Suchvorgnge mit unterschiedlichen Zahlen und Listen. Damit kann man die Durchschnittskosten eins Suchvorgangs berechnen. Es macht einen Unterschied, welche Flle wir betrachten. Beispielsweise verursacht die Suche im binren Baum im schlechtesten Fall linearen Aufwand, aber im Durchschnitt nur logarithmischen Aufwand. Fr die Berechnung des Durchschnitts mssen wir Annahmen darber treen, welche
271
Zahlen in welcher Reihenfolge in die Bume eingefgt wurden. blicherweise nehmen wir an, dass es sich um gleichverteilte Zufallszahlen handelt. Diese Annahme fhrt, wie man mit dem ntigen Hintergrundwissen ber Wahrscheinlichkeitsrechnung zeigen kann, zu logarithmischem Aufwand, weil Bume, die dabei entstehen, im Durchschnitt eine logarithmische Tiefe (Anzahl von Ebenen) haben. Allerdings sieht man in der Praxis deutlich huger, als die Theorie vermuten lsst, dass binre Bume entarten. Der Fehler liegt nicht an falschen Berechnungen, sondern fast immer daran, dass die implizit angenommene Gleichverteilung nicht gegeben ist. Die Zahlen, mit denen wir den Baum aufgebaut haben, sind die Ergebnisse von Berechnungen oder Messungen und stehen oft in Beziehungen zueinander. Das ist keine Gleichverteilung. Wenn wir Zahlen nur in aufsteigender Reihenfolge einfgen, beispielsweise die gemessenen Hhen eines aufsteigenden Ballons, bekommen wir garantiert einen zu einer Liste entarteten Baum mit dem schlechtesten mglichen Aufwand bei allen Zugrisoperationen. Ein Baum ist dafr einfach nicht geeignet. Wenn wir dagegen tatschlich gleichmig verteilte Zahlen einfgen, ist ein Baum fast immer recht ezient. Bei der Auswahl einer Datenstruktur mssen wir also auch auf die Verteilung der bentigten Daten achten. Binre Bume wurden entwickelt, um aus der zuflligen Verteilung von Daten Vorteile ziehen zu knnen. Viele Datenstrukturen und Algorithmen nutzen den Zufall aus. Eine Hashtabelle wie in Listing 4.18 ist ein gutes Beispiel dafr. Im Wesentlichen ist die Hashtabelle ein Array, in dem die Daten liegen. Der Index im Array, an dem ein bestimmtes Element zu nden ist, wird direkt aus dem Element errechnet. Diesen Index nennt man Hashwert. In Objekten der Klasse IntHashtable verwenden wir als Hashwert den Absolutwert1 des Elements modulo der Gre des Arrays. Dieser Wert liegt im Indexbereich des Arrays. Hashwerte knnen auf beliebige Weise berechnet werden, solange wiederholte Berechnungen fr dasselbe Datenelement immer denselben Hashwert ergeben. Da der Indexbereich der Hashtabelle beschrnkt ist, knnen auch die fr unterschiedliche Datenelemente berechneten Hashwerte gleich sein. In diesem Fall kollidieren die Elemente miteinander. Es gibt mehrere Mglichkeiten um mit Kollisionen umzugehen: Wir knnen z.B. linear die nchste freie Stelle in der Tabelle suchen oder einen weiteren Hashwert berechnen. In Listing 4.18 erlauben wir tatschlich mehrere Elemente am selben Index
1
Der Absolutwert einer nicht-negativen Zahl ist die Zahl selbst, und der einer negativen Zahl ist die negierte Zahl (die durch die Negation positiv wird). Der Absolutwert ist nie negativ.
272
Listing 4.18: Hashtabelle ganzer Zahlen mit verketteten Listen 1 public class IntHashtable { // Hashtabelle ganzer Zahlen 2 private IntListNode[] tab; // IntListNode aus List. 4.4 3 public IntHashtable (int size) { // Tabellengre size > 0 4 tab = new IntListNode[size]; 5 } 6 private int hashcode(int elem) { // berechne Hashwert (h) 7 return [Link](elem % [Link]); // 0 <= h < [Link] 8 } 9 public void add(int elem) { // Fge elem ein 10 int hash = hashcode(elem); 11 tab[hash] = new IntListNode (elem, tab[hash]); 12 } 13 public boolean contains(int elem) { // Suche elem 14 int hash = hashcode(elem); 15 return tab[hash] != null && tab[hash].contains(elem); 16 } 17 public void remove(int elem) { // Lsche elem 18 int hash = hashcode(elem); 19 if (tab[hash] != null) { 20 tab[hash] = tab[hash].remove(elem); 21 } 22 } 23 }
und speichern diese in einer verketteten Liste. Der Programmcode von IntHashtable entspricht dem von IntList in Listing 4.4, abgesehen davon, dass wir nicht nur einen Listenkopf, sondern ein Array von Listenkpfen haben. Der Hashwert bestimmt den zu verwendenden Listenkopf. Die Qualitt einer Hashtabelle wird hauptschlich von der Qualitt der berechneten Hashwerte bestimmt. Solange sich die Hashwerte gut auf den gesamten Indexbereich verteilen und die Tabelle gro genug ist, sind alle Zugrisoperationen sehr ezient: Einfgen, Suchen und Lschen haben im besten Fall nur konstanten zeitlichen Aufwand. Wenn jedoch alle Hashwerte gleich sind, bestimmte Werte berwiegen oder die Tabelle zu klein wird, dann dominieren die Kollisionsbehandlungen bzw. die Listenoperationen, die Kosten fr das Suchen und Lschen werden dadurch linear. In der Praxis sind gut dimensionierte Hashtabellen fr viele Anwendungen tatschlich sehr ezient. Allerdings fhren falsch dimensionierte Hashtabellen entweder zu einem erheblichen Speicherverbrauch oder zu fast genau so langen Zugriszeiten wie eine einfache verkettete Liste.
273
274
Listing 4.19: Mergesort 1 // sortierte elems aufsteigend; Methode steht in beliebiger Klasse 2 public static void mergesort(int[] elems) { 3 if ([Link] > 1) { // zu sortieren? 4 int[] left = new int[[Link] / 2]; 5 for (int i = 0; i < [Link]; i++) { // 1. Teil 6 left[i] = elems[i]; // wird kopiert 7 } 8 mergesort(left); // und sortiert 9 int[] right = new int[[Link] - [Link]]; 10 for (int i = 0; i < [Link]; i++) { // 2. Teil 11 right[i] = elems[[Link] + i]; // wird kopiert 12 } 13 mergesort(right); // und sortiert 14 // ab hier werden die zwei sortierten Teile zusammengefgt 15 for (int i=0, l=0, r=0; i < [Link]; i++) { 16 if (r >= [Link] // 2. Teil OK 17 || (l < [Link] // 1. Teil offen 18 && left[l] <= right[r])) { 19 elems[i] = left[l]; // vom 1. Teil 20 l++; 21 } else { 22 elems[i] = right[r]; // vom 2. Teil 23 r++; 24 } 25 } 26 } 27 }
sortiert sind. Die Strategie Teile-und-Herrsche bringt uns rasch zu einer vollstndigen Lsung: Wir teilen die zu sortierende Datenmenge in zwei Teile und sortieren die beiden Teile, anschlieend brauchen wir die beiden sortierten Teile nur mehr (auf gleiche Weise wie zwei Aktenstapel) zu einem Ganzen zusammenzufgen. Zum Sortieren der Teile verwenden wir rekursive Aufrufe des Algorithmus. Listing 4.19 zeigt eine einfache Implementierung von Mergesort, dem hier beschriebenen Sortierverfahren. Die Rekursion bricht ab, wenn das zu sortierende Array nur ein Element hat, denn dann ist es ohne Zutun bereits sortiert. Im Unterschied zu frheren Abschnitten dieses Kapitels sortieren wir hier nur einfache, als Argumente bergebene Arrays, keine Listen oder Bume, damit wir uns besser auf das Sortieren als die dahinterliegenden
275
Datenstrukturen konzentrieren knnen. Natrlich kann man die Algorithmen problemlos so abndern, dass Sie auf Listen funktionieren. Auf den algorithmischen Aufwand hat diese nderung keine nennenswerten Auswirkungen: Der zeitliche Aufwand T (n) setzt sich zusammen aus dem linearen Aufwand O(n) fr das Erstellen der Kopien und das Zusammen) fr das Sortieren der fgen der sortierten Teile sowie dem Aufwand 2 T ( n 2 beiden Teile. Mit dem ntigen mathematischen Hintergrundwissen kann man daraus einen zeitlichen Aufwand von T (n) = O(n log(n)) ableiten. Das ist im Vergleich zu Bubblesort recht ezient. Noch dazu muss man bedenken, dass immer derselbe Aufwand entsteht, im schlechtesten Fall genauso wie im besten. Nicht ganz so gut schaut der Algorithmus hinsichtlich des Speicherbedarfs aus: Weil wir Teile des Arrays wiederholt kopieren, haben wir auch einen Speicherbedarf von O(n log(n)). Durch einige Tricks und einen geschickteren Umgang mit Speicher knnten wir den Speicherbedarf auf O(n) reduzieren. Darauf verzichten wir hier aber, da der entsprechende Algorithmus nicht mehr einfach zu verstehen ist. Wie Mergesort zeigt, fhrt Teile-und-Herrsche auf natrliche Weise zu rekursiven Programmen, wenn zur Lsung der Teilprobleme wieder dieselbe Aufgabe auf einer Teilmenge der Daten zu lsen ist. Nicht jedes Teilproblem ist von derselben Art wie das Gesamtproblem, und daher fhrt nicht jede Anwendung der Strategie automatisch zur Rekursion. Umgekehrt beruht in gewisser Weise fast jede rekursive Methode auf Teile-und-Herrsche, da der rekursive Aufruf zur Lsung eines Teilproblems dient. Insoferne haben wir schon viele Anwendungen dieser Strategie in frheren Beispielen gesehen. Als Lsungsstrategie funktioniert Teile-und-Herrsche dann gut, wenn wir so wie bei der Suche nach einem Sortierverfahren vorgehen: Zuerst suchen wir einen vielversprechenden Lsungsansatz, der durchaus auch woanders als im engeren Bereich der Aufgabe angesiedelt sein kann. Vermutlich wird der Ansatz von einigen Voraussetzungen ausgehen, die nicht automatisch erfllt sind. Wir suchen also nach Mglichkeiten, die Voraussetzungen zu erfllen, wobei wir wieder Teile-und-Herrsche als Lsungsstrategie einsetzen knnen. Die Suche ist erfolgreich, sobald alle Voraussetzungen hinreichend gut erfllt sind. Wenn wir fr irgendeine Voraussetzung keine passende Lsung nden, dann mssen wir unser Glck mit einem anderen Ansatz versuchen. Teile-und-Herrsche ist keineswegs eine Technik, deren Anwendung immer zum Ziel fhrt. Vielmehr ist es eine Strategie, die uns bei der Suche nach einer kreativen Lsung untersttzt. Fehlende Kreativitt lsst sich durch keine Strategie ersetzen.
276
4.4.2 Pragmatische Sichtweise Obwohl Mergesort speziell hinsichtlich des Aufwands im schlechtesten Fall sehr gut abschneidet, verwendet man zum Sortieren heute viel huger Quicksort, siehe Listing 4.20. Quicksort ist in der Praxis meist (das heit, im Durchschnitt) schneller als Mergesort. Grnde dafr sind recht hohe Kosten fr das ntige Kopieren der Daten in Mergesort, die sich zwar in der theoretischen Berechnung des Aufwands nicht niederschlagen weil konstante Faktoren unbercksichtigt bleiben, aber bei Laufzeitmessungen sehr rasch bemerkbar machen, die Einfachheit wichtiger Teile von Quicksort, die fr kleine konstante Faktoren und damit durchschnittlich fr kurze Laufzeiten sorgen. Es gibt zahlreiche Varianten von Quicksort mit verschiedensten Optimierungen, welche die Laufzeit in bestimmten Situationen weiter zu verbessern helfen. Listing 4.20 zeigt eine einfache Variante, in der das Wesentliche an diesem Sortierverfahren leicht zu erkennen ist. Quicksort bestimmt zuerst irgendein Element der Datenstruktur zum sogenannten Pivot-Element (in Deutsch: Angel- bzw. Drehpunkt). Dann wird die Datenstruktur in zwei Teil geteilt: Ein Teil enthlt nur Elemente kleiner oder gleich dem Pivot-Element, der andere nur Elemente grer oder gleich dem Pivot-Element. Diese beiden Teile werden entsprechend Teile-undHerrsche durch rekursive Aufrufe sortiert und durch Hintereinanderstellen wieder zu einer Einheit zusammengefhrt. Das einfache Hintereinanderstellen reicht, da ja ein Teil nur kleinere und der andere nur grere Elemente enthlt. Im Unterschied zu Mergesort ist das Aufteilen der Elemente durch Bercksichtigung eines Pivot-Elements komplizierter, andererseits entfllt das aufwendige Zusammenfgen zweier Teile. Die Ezienzvorteile von Quicksort im durchschnittlichen Fall ergeben sich vor allem aus der Einfachheit des Algorithmus. In der Variante in Listing 4.20 entfllt das Kopieren der Elemente und Hintereinanderstellen der beiden sortierten Teile, weil die zu sortierenden Teile des Arrays einfach nur ber den linken und rechten Index (l und r) bestimmt werden. Man braucht den linken Teil nur zwischen den Indizes l und i-1 und den rechten zwischen i und r sortieren, und schon ist das Array zwischen l und r sortiert. Solche Verfahren, bei denen nicht kopiert zu werden braucht, nennt man In-Place -Sortierverfahren.
277
Listing 4.20: Quicksort 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 // sortiere elems aufsteigend; Methode steht in beliebiger Klasse public static void quicksort(int[] elems) { quicksort(elems, 0, [Link] - 1); } // sortiere elems aufsteigend von Index l bis Index r (inklusive) private static void quicksort(int[] elems, int l, int r) { int i = l; // luft von links nach rechts int j = r; // luft von rechts nach links int pivot = elems[(l+r)/2]; // trennt linken/rechten Teil while (i <= j) { // i und j aneinander vorbei? while (elems[i] < pivot) { // alles kleiner pivot ... i++; // ... bleibt im linken Teil } while (pivot < elems[j]) { // alles grer pivot ... j--; // ... bleibt im rechten Teil } // hier gilt: elems[i] >= pivot >= elems[j] if (i <= j) { // nicht aneinander vorbei? int h = elems[i]; // Tausch elems[i],elems[j] elems[i] = elems[j]; // unverndert wenn i == j elems[j] = h; i++; // und weiter zu ... j--; // ... nchsten Indizes } } if (l < i-1) { // links mehrere Elemente? quicksort(elems, l, i-1); // sortiere linken Teil } if (i < r) { // rechts mehrere Elemente? quicksort(elems, i, r); // sortiere rechten Teil } }
Die Zuordnung der Elemente zu den beiden Teilen ist auch nicht sehr aufwendig: Der Algorithmus luft ber die Indexvariablen i und j sowohl von links als auch von rechts solange in Richtung Mitte, bis sich i und j treen. Dabei werden Elemente, die auf der linken Seite stehen, aber zur rechten gehren, mit solchen getauscht, die auf der rechten Seite stehen, aber zur linken gehren. Elemente mit einem Wert gleich dem Pivot-Element werden in den Tausch mit einbezogen. Dadurch verschiebt sich die Trennlinie zwischen dem linken und rechten Teil je nach Bedarf:
278
Die Mitte ist dort, wo i und j sich treen. Gelegentlich werden auch gleiche Elemente getauscht, obwohl das gar nicht notwendig wre. Zustzliche Fallunterscheidungen zum Verhindern des unntigen Tauschens wrden die Laufzeit eher erhhen als verringern. Quicksort hat, wie Mergesort, durchschnittliche zeitliche Kosten von O(n log(n)), aber im schlechtesten Fall von O(n2 ). Wie oben argumentiert, sind die blicherweise gemessenen Laufzeiten fr Quicksort jedoch deutlich krzer als fr Mergesort. Dieser scheinbare Widerspruch lsst sich dadurch klren, dass der schlimmste Fall bei Quicksort fast nie auftritt und Quicksort einen deutlich kleineren konstanten Faktor als Mergesort hat. Ein kritischer Aspekt ist die Auswahl des Pivot-Elementes. Sein Wert entscheidet letztendlich, an welcher Stelle das Array in zwei Teile geteilt wird. Es ist anzustreben, dass beide Teile ungefhr gleich gro sind, denn dann bleiben die Kosten bei O(n log(n)). Quadratischer Aufwand entsteht, wenn jede Aufteilung einen Teil mit nur einem Element und einen zweiten Teil mit dem ganzen Rest liefert. Bei Zufallszahlen ist das uerst unwahrscheinlich. Prinzipiell kann man jedes Element im Array als Pivot-Element whlen. Wenn man jedoch beispielsweise immer das erste Element whlt, dann fhrt leider gerade ein bereits sortiertes Array zu quadratischem Aufwand: Das Pivot-Element ist das einzige Element des linken Teils. Deshalb wird das Pivot-Element in Listing 4.20 aus der Mitte genommen. Um sicher zu gehen, vergleicht man manchmal das erste Element, das letzte Element und ein Element aus der Mitte und whlt jenes mit dem mittleren Wert. Eine Garantie fr ein gut gewhltes PivotElement gibt es nicht. Wenn man quadratischen Aufwand unbedingt ausschlieen will, greift man beispielsweise auf Mergesort zurck. 4.4.3 Strukturelle hnlichkeiten Hinter vielen bisher vorgestellten Verfahren steckt eine hnliche Struktur. Beispielsweise haben wir in Abschnitt 4.3.2 einen binren Baum aufgebaut und dessen Elemente sortiert ausgegeben. Dafr war ein Aufwand von O(n log(n)) im Durchschnitt und O(n2 ) im schlechtesten Fall ntig. Fr das Sortieren eines Arrays mittels Quicksort haben wir denselben Aufwand. Dahinter steckt kein Zufall, sondern eine gemeinsame Struktur: In beiden Fllen haben wir etwas wiederholt in zwei Teile geteilt, einen linken und einen rechten Teilbaum bzw. einen linken und rechten Teil beim Sortieren. Die genaue Aufteilung wird in beiden Fllen vom Zufall gesteuert, und es ist mglich, dass die Strukturen entarten ein Baum zu einer
279
Listing 4.21: Anzahl der Versuche zum Erraten einer unbekannten Zahl // Anzahl der Versuche, um Zufallszahl aus max Zahlen zu finden public static int versuche(int max) { UnbekannteZahl zuErraten = new UnbekannteZahl(max); int i = 0; // untere Grenze int j = max - 1; // obere Grenze for (int anzahl = 0; ; anzahl++) { // zhle Versuche: int k = i + ((j - i) / 2); // probiere die Mitte if ([Link](k)) { j = k - 1; // neue obere Grenze } else if ([Link](k)) { return anzahl; } else { i = k + 1; // neue untere Grenze } } // Schleife terminiert sptestens wenn i und j sich treffen }
Liste, zwei gleichmig aufgeteilte Hlften des Arrays zum wiederholten Abspalten nur eines Elements. Die hnlichkeit besteht trotz unterschiedlicher Ziele, ganz anderer Datenstrukturen und obwohl der wesentliche Teil bei Listen in den Datenstrukturen und beim Sortieren im Algorithmus enthalten ist. Auch wenn solche hnlichkeiten manchmal schwer zu beschreiben sind, kann man sie mit etwas Erfahrung rasch erkennen. Die Kunst des Programmierens besteht hug darin, Strukturen in einem Problem zu erkennen und bekannte Lsungsanstze fr hnlich strukturierte Probleme zu bernehmen. Mit der Zeit lernen wir viele Strukturen kennen, die wir in unterschiedlichster Form in unsere Programme einbauen. Viele Strukturen stammen aus unseren tglichen Erfahrungen, nicht nur im Zusammenhang mit dem Programmieren. Nehmen wir als Beispiel die Methode versuche in Listing 4.21, die versucht, eine Zufallszahl mglichst rasch zu erraten, und die Anzahl der Versuche zurckgibt. Dazu setzen wir ein Suchverfahren ein, das wir vermutlich schon beim Ausprobieren von Zahlenraten in Abschnitt 1.1 intuitiv entwickelt haben: Wir betrachten den zur Verfgung stehenden Zahlenbereich, versuchen unser Glck mit einer Zahl aus der Mitte und grenzen, falls die Zahl nicht schon gefunden wurde, den zur Verfgung stehenden Zahlenbereich auf etwa die Hlfte ein. Nach wenigen Versuchen ist der Zahlenbereich so stark eingeschrnkt, dass nur mehr eine Zahl in Frage
280
Listing 4.22: Binre Suche in einem sortierten Array // suche x in elems und gib dessen Index zurck, sonst -1 // elems muss aufsteigend sortiert sein; Klasse beliebig public static int index(int x, int[] elems) { int i = 0; // untere Grenze int j = [Link] - 1; // obere Grenze while (i <= j) { int k = i + ((j - i) / 2); // probiere die Mitte if (x < elems[k]) { j = k - 1; // links weitersuchen } else if (x == elems[k]) { return k; } else { i = k + 1; // rechts weitersuchen } } return -1; // x nicht gefunden }
kommt. Eine Zahl zwischen 0 und 99 haben wir so mit hchstens sieben Versuchen erraten, hug werden wir circa fnf Versuche brauchen. Generell brauchen wir fr n mgliche Zahlen hchstens etwa ld(n) Versuche. Wir nehmen den Logarithmus-Dualis, da wir bei jedem Versuch den Suchbereich auf ungefhr die Hlfte reduzieren. Der Aufwand von versuche entspricht daher auch O(log(n)), da ld(n) = log(n)/log(2) gilt und log(2) ein konstanter Faktor ist. Das ist der hchstmgliche Aufwand. Durchschnittlich ist der Aufwand nur geringfgig kleiner, da die meisten Zahlen erst im stark eingeschrnkten Zahlenbereich gefunden werden. Meist ndet man dieses Verfahren ganz natrlich auch ohne Hilfe und ohne Kenntnis von Logarithmen. Das ist ein Zeichen dafr, dass entsprechende Strukturen unserem Unterbewusstsein schon bekannt sind. Wir brauchen sie nur mehr abzurufen. Leider lassen sich derart gute Algorithmen nicht immer so leicht nden, aber irgendein brauchbares Lsungsverfahren knnen wir fr die meisten Probleme ganz intuitiv nden. Wie Listing 4.22 zeigt, lsst sich der Algorithmus zum Erraten einer unbekannten Zahl zur binren Suche weiterentwickeln, einem ezienten Suchalgorithmus nach Zahlen in einem sortierten Array. Die Strukturen und damit der Aufwand sind fast identisch. Mit etwas Fantasie sind auch hnlichkeiten zwischen der binren Suche und der Suche in einem binren
281
Baum zu erkennen. Auch im binren Baum beginnt die Suche in der Mitte und setzt sich im linken oder rechten Teilbaum fort. Anders als ein binrer Baum kann ein sortiertes Array jedoch nicht zu einer Liste entartet sein.
282
Listing 4.23: Generische verkettete Liste 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 public class GenList<A> { // Liste von Objekten von A private ListNode<A> head = null; // Listenkopf public void add(A elem) { // fge elem am Anfang ein head = new ListNode<A>(elem, head); } public boolean contains(A elem) { // elem in Liste enthalten? return head != null && [Link](elem); } public void remove(A elem) { // Lschen des ersten Vorkommens von elem // Liste unverndert wenn elem nicht vorkommt if (head != null) { head = [Link](elem); // neuer Anfang nach Lschen } } } class ListNode<A> { // Listenknoten private A elem; // das eigentliche Listenelement private ListNode<A> next; // nchster Knoten = Listenrest ListNode(A elem, ListNode<A> next) { [Link] = elem; [Link] = next; } boolean contains(A e) { // suche e im Rest der Liste return [Link](elem) || (next!=null && [Link](e)); } ListNode<A> remove(A e) { // lsche e aus Restliste if ([Link](elem)) { // ist this zu lschen? return next; } else if (next != null) { next = [Link](e); } return this; } }
pen enthalten, welche die Typparameter in den Denitionen generischer Klassen ersetzen. Beispielsweise ist GenList<String> der Typ, der entsteht, wenn man in der Klasse GenList<A> jedes Vorkommen des Typparameters A durch String ersetzt. Ein Objekt von GenList<String> ist also eine Liste von Zeichenketten. Beim ersten Kontakt mit Generizitt ist es verwirrend, dass innerhalb der Klassen GenList<A> und ListNode<A> der Typ ListNode<A>
283
vorkommt. Auch dafr gilt, dass ListNode<A> fr den Typ steht, der entsteht, wenn man A durch den Typ ersetzt, fr den A steht. Sobald A durch String ersetzt wird, steht auch ListNode<A> fr ListNode<String>. Mit etwas Erfahrung ist die Verwendung von Typparametern statt Typen ganz selbstverstndlich. Ein Vergleich beliebiger Objekte mittels == funktioniert zwar, liefert aber beispielsweise fr Zeichenketten nicht das gewnschte Ergebnis. Deswegen wurde der Vergleich mittels == durch einen mittels equals ersetzt. Diese Methode ist programmierbar, sodass durch sie ganz unterschiedliche Arten von Vergleichen realisiert sein knnen. Das macht die generische Liste exibel einsetzbar. Man kann jedoch nicht generell sagen, dass in generischen Klassen equals statt == zu verwenden ist. Der Unterschied ist semantischer Natur. Es kommt darauf an, welche Art von Vergleich bentigt wird. Generizitt ist ein sehr wertvolles Hilfsmittel vor allem bei der Erstellung von Container-Klassen, also von Klassen, deren Objekte andere Objekte enthalten und den Zugri darauf organisieren. Im Wesentlichen implementieren Container-Klassen Datenstrukturen. Man braucht nur eine generische Container-Klasse schreiben und bekommt dadurch ContainerKlassen fr alle mglichen Arten von Inhalten im Container. Die Methode equals ist in Object als gleichbedeutend mit == vordeniert, kann aber wie beispielsweise in Student in Listing 4.24 berschrieben werden. Zwei Objekte von Student werden genau dann als gleich betrachtet, wenn die Matrikelnummern gleich sind. Damit liefert contains in einem Objekt von GenList<Student> immer true, wenn ein Objekt von Student mit der gesuchten Matrikelnummer vorhanden ist, egal ob das gefundene Objekt von Student identisch mit dem gesuchten ist oder nicht. Auch String berschreibt equals, sodass alle Zeichen der Zeichenketten verglichen werden. Als eine wenig attraktive Alternative zur Generizitt knnten wir eine Container-Klasse schreiben, die beliebigen Inhalt hat. Das wre beispielsweise eine verkettete Liste, die beliebige Instanzen von Object enthlt. Allerdings kann man damit kaum verhindern, dass auch unerwnschte Objekte in der Datenstruktur landen. Bevor Generizitt mit Version 1.5 zu Java hinzugekommen ist, musste man so programmieren. Inzwischen ist diese Technik berholt, auch wenn man gelegentlich noch immer auf alten Java-Code stt, der auf diese Art programmiert ist. Statt nicht-
284
Listing 4.24: Ausschnitt aus einer Klasse mit spezieller Vergleichsmethode public class Student { private int mnr; // Matrikelnummer ... // weitere Variablen und Konstruktoren public boolean equals(Object that) { // this gleich that? return that != null && [Link]() == [Link]() && mnr==((Student)that).mnr; } ... // weitere Methoden }
generischer Container-Klassen, die Instanzen von Object enthalten, sollte man heute nur mehr generische Container-Klassen schreiben. Falls man, was selten vorkommt, wirklich eine Datenstruktur braucht, die Beliebiges enthlt, kann man noch immer den Typparamenter durch Object ersetzen. Ein Beispiel dafr ist GenList<Object>. Der Compiler garantiert jedoch, dass beispielsweise ein Objekt von GenList<String> nur Zeichenketten enthlt. Da Generizitt erst nachtrglich zu Java hinzugefgt wurde, ergeben sich wegen der notwendigen Kompatibilitt zu lteren Versionen leider einige Nachteile, die in anderen Programmiersprachen nicht bestehen: Namen generischer Typen wie GenList haben auch ohne spitze Klammern eine semantische Bedeutung, die fast nie gebraucht wird und auf die wir deswegen nicht nher eingehen. Es gibt keine Fehlermeldung, wenn wir spitze Klammern vergessen. Im besten Fall bekommen wir eine unverstndliche Compilermeldung (als Note bezeichnet). Deswegen mssen wir besonders auf spitze Klammern achten, um semantisch falsche Programme zu vermeiden. Typparameter drfen nicht berall vorkommen, wo Typen notwendig sind. Beispielsweise knnen wir keine Instanz eines Typparameters erzeugen und kein Array anlegen, das Instanzen eines Typparameters enthlt. Auch in Typabfragen mittels instanceof und in Typumwandlungen ist die Verwendung von Typparametern eingeschrnkt. Typparameter knnen nur durch Referenztypen ersetzt werden, nicht jedoch durch elementare Typen wie int, double oder boolean.
285
Der letzte Punkt wiegt anscheinend besonders schwer, weil dadurch die generische Liste doch kein Ersatz fr die Liste ganzer Zahlen sein kann. Ganz so schlimm ist es glcklicherweise nicht. Um dieses Problem zu umgehen gibt es in Java zu jedem elementaren Typ auch einen entsprechenden Referenztyp. Der Referenztyp zu int ist Integer, der zu boolean ist Boolean, der zu double ist Double und so weiter. Jeder solche Referenztyp ist durch eine einfache Klasse deniert, die (hnlich einer Schachtel oder Box ) eine Instanz des entsprechenden elementaren Typs und eine Reihe von Zugris- und Vergleichsoperationen darauf enthlt. Beispielsweise vergleicht equals in diesen Klassen die Werte in der Box. Statt dem nicht erlaubten Typ GenList<int> knnen wir den Typ GenList<Integer> verwenden. Wenn beispielsweise x eine Instanz von int und list eine Instanz von GenList<Integer> ist, knnen wir [Link](x) aufrufen. Der Compiler wandelt x automatisch in eine Instanz von Integer um. Diese automatische Umwandlung nennt sich Autoboxing, und die automatische Umwandlung einer Instanz von Integer in eine von int Autounboxing. Java untersttzt Autoboxing und Autounboxing fr alle elementaren Typen bzw. entsprechenden Referenztypen. 4.5.2 Gebundene Generizitt Einfache Generizitt reicht fr einfache Datenstrukturen wie Listen, aber z.B. nicht fr binre Bume und Sortieralgorithmen. Dafr sind Grenvergleiche zwischen Elementen ntig. Grenvergleiche sind aber nicht auf allen Objekten sinnvoll oder mglich. Gebundene Generizitt lst dieses Problem: Bei der Deklaration eines Typparameters gibt man einen Typ als Schranke an. Der durch die Schranke gebundene Typparameter kann nur durch einen Untertyp der Schranke ersetzt werden, und daher sind die in einer Instanz der Schranke zugreifbaren Methoden auch in einer Instanz des Typparameters zugreifbar. Listing 4.25 veranschaulicht dieses Konzept an einer Erweiterung der Liste: Die Werte aller in eine Liste eingefgten Elemente sollen aufsummiert werden. Fr jeden Elementtyp kann der Wert etwas anderes bedeuten. Wir legen nur fest, dass ein Aufruf der Methode value den Wert zurckgibt. Nun hat nicht jedes Objekt eine solche Methode. Daher legen wir durch eine Schranke auf dem Typparameter A fest, dass nur Untertypen des Interfaces HasValue, das die Methode deklariert, den Typparameter ersetzen drfen. Im Beispiel ist SumList<Student>
286
Listing 4.25: Generische List mit Aufsummierung public interface HasValue { int value(); } // als Schranke verwendeter Typ // diese Methode wird bentigt
public class SumList<A extends HasValue> { // Schranke auf A private ListNode<A> head = null; private int sum = 0; public void add(A elem) { // elem ist Instanz von HasValue head = new ListNode<A>(elem, head); sum += [Link](); // daher [Link]() aufrufbar } ... } public class Student implements HasValue { // Untertyp d. Schranke // daher SumList<Student> erlaubt private int numberOfCourses; public int value() { // Implementierung der bentigten Methode return numberOfCourses; } }
erlaubt, da Student das Interface und damit value implementiert. Dagegen ist SumList<Object> nicht erlaubt. Syntaktisch steht die Schranke in der Typparameterdeklaration nach dem Namen des Typparameters und extends in den spitzen Klammern. Hier steht immer extends, auch wenn die Schranke als Interface deniert ist. Innerhalb der generischen Klasse darf auf in der Schranke sichtbare Methoden und Variablen von Instanzen des Typparameters zugegrien werden. Ein nicht gebundener Typparameter, also einer ohne Schranke, entspricht einem durch die Schranke Object gebundenen Typparameter. Die generische Implementierung eines Baums kmpft mit einer weiteren Schwierigkeit: Ein Grenvergleich zwischen zwei Elementen ist nur sinnvoll, wenn beide Elemente einen auf gleiche Art vergleichbaren Typ haben. Die Methode equals vergleicht this mit einer Instanz von Object, was nur mglich ist, weil ein Vergleich mit einer Instanz eines unbekannten anderen Typs immer false liefern kann. Dieser einfache Ausweg besteht bei Grenvergleichen nicht. Wir mssen sicherstellen, dass auch der formale Parameter der Vergleichsmethode den gewnschten Typ hat.
287
Listing 4.26: Generischer binrer Baum mit bentigten Klassen public interface Comparable<T> { // Ergebnis<0: this < that int compareTo(T that); // =0: [Link](that) } // >0: this > that public class GenTree<A extends Comparable<A>> { // binrer Baum private TreeNode<A> root = null; public void add(A elem) { /* fge elem ein */ ... } public boolean contains(A elem) { /* suche elem */ ... } public void remove(A elem) { /* lsche elem */ ... } } class TreeNode<A extends Comparable<A>> { // Knoten im Baum private A elem; // eigentliches Element private TreeNode<A> left = null; // linker Teilbaum private TreeNode<A> right = null; // rechter Teilbaum TreeNode(A e) { elem = e; } boolean contains(A e) { // suche e im Baum int c = [Link](elem); // Ergebnis des Vergleichs if (c < 0) { // e in linkem Teilbaum? return left != null && [Link](e); } else if (c == 0) { // e gefunden return true; } else /* c > 0 */ { // e in rechtem Teilbaum? return right != null && [Link](e); } } void add(A e) { /* fge e in Baum ein */ ... } TreeNode<A> remove(A e) { /* lsche e aus Baum */ ... } } public class Student implements Comparable<Student> { private int mnr = ...; public int compareTo(Student that) { // Vergleich ber mnr return [Link] - [Link]; } public boolean equals(Object that) { // kompatibel: compareTo return that != null && [Link]() == [Link]() && mnr == ((Student)that).mnr; } }
Rekursive gebundene Generizitt bietet dafr eine Lsung, wie die in Listing 4.26 skizzierte generische Implementierung eines binren Baumes zeigt: Der Typparameter A ist durch Comparable<A> gebunden, der ge-
288
rade deklarierte Typparameter kommt also in seiner eigenen Schranke vor eine Form von Rekursion. Der Typparameter T in Comparable<T> bestimmt den Typ des formalen Parameters von compareTo. Durch die Rekursion in der Schranke wird T durch denselben Typ ersetzt wie A in GenTree<A>, sodass this und that in compareTo denselben Typ haben mssen. Baumoperationen rufen diese Methode auf, wie beispielhaft fr contains gezeigt. Die Klasse Student in Listing 4.26 implementiert Comparable<Student> und erfllt damit die Bedingung, dass A Untertyp von Comparable<A> ist. Also ist GenTree<Student> erlaubt. In der Praxis brauchen wir das Interface Comparable<T> nicht zu denieren, da es bereits genau so wie in Listing 4.26 vordeniert ist. Glcklicherweise implementieren viele vordenierte Klassen dieses Interface. Beispielsweise wird Comparable<Integer> von Integer implementiert und Comparable<String> von String. Wir knnen also problemlos Typen wie GenTree<Integer> und GenTree<String> verwenden. 4.5.3 Abstraktionen ber Datenstrukturen Generizitt ermglicht eine bestimmte Form der Abstraktion: Wir knnen Code, den wir nur einmal schreiben, fr Datenstrukturen ber unterschiedlichen Elementtypen einsetzen. Die Datenstrukturen selbst bleiben dabei stets gleich. Daneben htten wir oft auch gerne eine andere Form der Abstraktion, bei der zwar die Elementtypen gleich bleiben, aber die Datenstrukturen variieren knnen. Dabei bekommen wir Zugri auf eine Sammlung von Daten, mssen aber nicht zwischen Details von Listen, Bumen, etc. unterscheiden. Letztere Form der Abstraktion lsst sich ber Untertypbeziehungen realisieren. Listing 4.27 zeigt ein Beispiel fr die Verwendung von Untertypbeziehungen zwischen generischen Typen. Das Interface Collection<E> beschreibt Methoden, die man von vielen Arten von Datenstrukturen erwartet. So ist es mglich, dass zahlreiche Klassen wie GenList<A> und GenTree<A> dieses Interface implementieren. Untertypbeziehungen funktionieren auf generischen Typen genauso wie auf nichtgenerischen. Bei der Spezikation der Obertypen werden die Typparameter des Untertyps mit denen der Obertypen in Beziehung gesetzt, sodass beispielsweise der Typparameter A von GenList<A> den Typparameter E von Collection<E> ersetzt. Typparameter der Untertypen knnen im Vergleich zu denen der Obertypen strker eingeschrnkt sein. So ist ist der
289
Listing 4.27: Datensammlung: Interface, Implementierungen, Verwendung public interface Collection<E> void add(E e); // boolean contains(E e); // void remove(E e); // } { fge e zur Datenstruktur hinzu ist e in Datenstruktur enthalten? lsche ein e (falls vorhanden)
public class GenList<A> implements Collection<A> { ... } public class GenTree<A extends Comparable<A>> implements Collection<A> { ... } public class WordsInUse { // private Collection<String> words; public WordsInUse() { words = new GenTree<String>; } public boolean isNew(String word) if ([Link](word)) { return false; } else { [Link](word); return true; } } } Verwaltung bekannter Wrter // Sammlung der Wrter // Datenstruktur nur hier { // word nicht vorhanden?
Typparameter A von GenTree<A> durch die Schranke Comparable<A> gebunden, nicht jedoch E von Collection<E>. Allerdings werden durch die Einschrnkung der Typparameter auch die Untertypbeziehungen eingeschrnkt. Beispielsweise hat Collection<String> die Untertypen GenList<String> und auch GenTree<String> weil String ein Untertyp der Schranke ist. Aber Collection<UnbekannteZahl> hat nur den Untertyp GenList<UnbekannteZahl>. Aufgrund der Schranke ist GenTree<UnbekannteZahl> verboten. Dagegen drfen einander entsprechende Typparameter in Obertypen nicht strker eingeschrnkt sein als in Untertypen. Wie Student in Listing 4.26 zeigt, knnen Obertypen Typparameter haben, obwohl es in Untertypen keine gibt. Auch Untertypen knnen Typparameter haben, die in Obertypen nicht vorkommen. Listing 4.27 gibt ein Beispiel fr die sinnvolle Verwendung abstrakter Datenstrukturen wie Collection<E>. Die Klasse WordsInUse ver-
290
waltet eine Menge von Wrtern. Jeder Aufruf von isNew fragt an, ob ein bestimmtes Wort in der Menge enthalten ist. Wenn nicht, wird es eingefgt. Fr WordsInUse spielt es (abgesehen von der Ezienz) keine Rolle, ob die Daten in einer verketteten Liste, einem binren Baum oder einer anderen Datenstruktur abgelegt werden. Nur zur Erzeugung der Datenstruktur muss deren Art bekannt sein. Wir sorgen durch Verwendung des Obertyps als Typ der Variablen words dafr, dass die Art der Datenstruktur auer an einer Stelle im Konstruktor unbekannt bleibt. Dadurch knnen wir die Art der Datenstruktur leicht durch eine Programmnderung an nur einer Stelle gegen eine andere austauschen. Das erhht die Wartbarkeit. In groen Programmen ist diese Form der Abstraktion ber Implementierungsdetails noch viel wichtiger als in diesem kleinen Beispiel. Generizitt und Untertypbeziehungen stellen unterschiedliche Abstraktionsformen zur Verfgung, die im Allgemeinen nicht gegeneinander austauschbar sind. Wie wir in Kapitel 3 gesehen haben, dienen Untertypbeziehungen dazu, Implementierungsdetails lokal gekapselt zu halten und durch Ersetzbarkeit den Austausch bzw. die Wiederverwendung von Programmteilen zu erleichtern. Wie obiges Beispiel zeigt, gilt das auch fr Untertypbeziehungen auf generischen Typen. Generizitt selbst untersttzt die Ersetzbarkeit nicht. Stattdessen erspart uns Generizitt durch direkte Wiederverwendung von Programmcode Arbeit beim Schreiben von ContainerKlassen. Rekursiv gebundene Generizitt erlaubt uns, Typen von formalen Parametern so einzuschrnken, dass sie gleich den Klassen sind, in denen die Methoden stehen. Das haben wir am Beispiel von compareTo in Listing 4.26 gesehen. Derartiges lassen Untertypbeziehungen wiederum nicht zu. In der praktischen Programmierung brauchen wir beide Formen der Abstraktion, Untertypbeziehungen fast berall, Generizitt speziell im Zusammenhang mit Container-Klassen. Im Zusammenhang mit Generizitt untersttzt Java einige weitere Konzepte, um die Fhigkeit zur Abstraktion weiter auszubauen. So knnen einzelne Methoden, nicht nur ganze Interfaces oder Klassen, generisch sein. Beispielsweise deniert
Listing 4.28: Generische Identittsfunktion public static <A> A ident(A x) { return x; }
eine generische Methode, die einfach nur den formalen Parameter zurckgibt. Sowohl der Typ des Parameters x als auch der Ergebnistyp ist durch
291
den Typparameter A festgelegt, der in den spitzen Klammern unmittelbar vor dem Ergebnistyp deklariert ist. Man kann die Methode mit einem Argument jeden beliebigen Referenztyps aufrufen und bekommt ein Ergebnis desselben Typs zurck. Da man den Umgang mit Algorithmen, Datenstrukturen und Lsungsstrategien auch ganz gut ohne generische Methoden verstehen kann, gehen wir hier nicht nher darauf ein. Ein weiteres Konzept sind Wildcard-Typen, welche die Typen, die Typparameter ersetzen, ganz oder teilweise undeniert lassen. Beispielsweise ist Collection<?> der Typ einer Datenstruktur mit unbekanntem Inhalt. Eine Instanz von Collection<? extends UnbekannteZahl> enthlt Elemente eines beliebigen Untertyps von UnbekannteZahl, und eine Instanz von Collection<? super UnbekannteZahl> enthlt Objekte eines beliebigen Typs, der Obertyp von UnbekannteZahl ist. Sinnvolle Verwendungen solcher Wildcard-Typen sind kompliziert und zudem stark eingeschrnkt. Deswegen gehen wir in diesem Skriptum nicht nher darauf ein. 4.5.4 Iteratoren Hug mchte man nacheinander alle Elemente eines Containers auslesen, hnlich wie wir bereits in der Klasse Zahlenraten eine Zahl nach der anderen eingelesen haben. Das bewerkstelligen wir mit einem Iterator. Die Klasse Print in Listing 4.29 zeigt, wie Iteratoren verwendet werden. In diesem Beispiel werden alle Zeichenketten in einem Container lines nacheinander ausgelesen und ausgegeben. Dazu erzeugt der Ausdruck [Link]() einen neuen Iterator i ber dem Container. Jeder Aufruf von [Link]() liefert eine Zeichenkette aus dem Container zurck, und [Link]() berprft, ob es weitere Zeichenketten gibt. Aufrufe von [Link]() verndern den Zustand des Iterators, lassen aber den Container selbst unverndert. Ein Container, auf dem ein Iterator erzeugt werden kann, heit auch Aggregat. In Java sollte jedes Aggregat das Interface Iterable<E> implementieren, das nur eine Methode zum Erzeugen eines Iterators enthlt. Auch Collection<E> erweitert Iterable<E> und untersttzt damit Iteratoren. Das Interface Iterator beschreibt die Methoden eines Iterators. Alle diese Schnittstellen sind hnlich wie in Listing 4.29 (aber teilweise etwas umfangreicher) im Java-System vordeniert.
292
Listing 4.29: Verwendung eines Iterators public interface Iterator<E> { A next(); boolean hasNext(); } public interface Iterable<E> { Iterator<E> iterator(); } // Iterator ber einem Aggregat // gib nchstes Element zurck // gibt es weitere Elemente?
// Objekte von Collection<...> sind Aggregate: public interface Collection<E> extends Iterable<E> { ... } public class Print { // Iterator-Verwendung (Beispiel) private Collection<String> lines; public Print(Collection<String> ls) { lines = ls; } public void print() { Iterator<String> i = [Link](); while ([Link]()) { [Link]([Link]()); } } }
Listing 4.30 zeigt wesentliche Teile der Implementierung eines Iterators auf einer verketteten Liste. Es ist wichtig, dass der Iterator selbst wei, an welcher Stelle in der Liste er sich gerade bendet. Dazu dient die Variable node in ListIter<A>. Bei jeder Ausfhrung von next wird der Inhalt der Variablen um einen Listenknoten weiter nach hinten verschoben. Eine kleine zustzliche Erschwernis entsteht in der Implementierung in Listing 4.30 dadurch, dass Objekte von ListIter<A> nicht direkt auf solche von ListNode<A> zugreifen drfen. Dieses Problem ist durch sogenannte Getter-Methoden gelst, die Lesezugrie auf Variablen ermglichen. blicherweise wollen wir Getter-Methoden so gut es geht vermeiden, aber sie sind dem direkten Zugri auf Variablen eines anderen Objekts, das zu einer anderen Klasse gehrt, meist dennoch vorzuziehen.2
2
Java untersttzt innere Klassen, also Klassen, die zu bestimmten Objekten gehren. Innere Klassen ermglichen den unbeschrnkten Zugri auf diese Objekte, auch auf private
293
Listing 4.30: Implementierung eines Iterators ber einer verketteten Liste public class GenList<A> implements Collection<A> { private ListNode<A> head = null; public Iterator<A> iterator() { // neuer Iterator auf Liste return new ListIter<A>(head); } ... } class ListIter<A> implements Iterator<A> { // Listeniterator private ListNode<A> node; // eigenes node fr jeden Iter. ListIter(ListNode<A> head) { // initial. mit Listenanfang node = head; } public A next() { // gib nchstes Element in der if (node != null) { // Reihenfolge der Liste zurck ListNode<A> result = node; node = [Link](); return [Link](); } return null; } public boolean hasNext() { // weitere Listenelemente? return node != null; } } class ListNode<A> { // Erweiterung fr Iterator ntig private A elem; private ListNode<A> next; ListNode(A elem, ListNode<A> next) { [Link] = elem; [Link] = next; } A getElem() { return elem; } // Getter fr elem ListNode<A> getNext() { return next; } // Getter fr next ... }
Es ist mglich, mehrere Iteratoren gleichzeitig auf demselben Container zu verwenden. Jeder der Iteratoren hat seine eigene Variable node und kann somit unbeeinusst von anderen Iteratoren die Liste durchwandern.
Teile. Wenn man Iteratoren als innere Klassen der Aggregate implementiert, gibt es keine Probleme mit der Zugreifbarkeit, und man kann sich die Getter-Methoden ersparen. Wir haben hier auf innerer Klassen verzichtet um uns auf das Wesentliche zu konzentrieren und die Lsung nicht mit weiteren Sprachkonzepten zu berfrachten.
294
Listing 4.31: Generischer Stack als verkettete Liste (ListNode aus Listing 4.30) 1 public class GenStack<A> { // Stack, als Liste implementiert 2 private ListNode<A> top = null; // oberstes Stackelement 3 public void push(A elem) { // gib elem auf Stack 4 top = new ListNode<A>(elem, top); 5 } 6 public A pop() { // nimm oberstes Element von Stack 7 if (top != null) { // falls vorhanden; sonst null 8 A result = [Link](); 9 top = [Link](); 10 return result; 11 } 12 return null; 13 } 14 public boolean isEmpty() { // ist der Stack leer? 15 return top == null; 16 } 17 }
Das ist ein wesentlicher Unterschied zu Methoden in einem Container, ber die man direkt auf Elemente zugreift: Zugrisoperationen hnlich dem next, aber direkt im Container und ohne Iterator, verndern den Container; mehrere gleichzeitige Durchlufe wrden sich gegenseitig beeinussen. Als Beispiel dafr dient die Stackimplementierung in Listing 4.31: Eine Ausfhrung von pop nimmt zwar ein Element vom Stack, sehr hnlich wie das eine Ausfhrung von next in einem Iterator macht. Allerdings wird das Element durch pop tatschlich aus dem Container entfernt, whrend der Container selbst durch einen Iterator nicht verndert wird. Auerdem beeinusst das Einfgen oder Lschen eines Listenelements den Iterator nicht bzw. beim Lschen nur auf die erwartete Weise. Der Iterator ist robust gegenber diesen Operationen. Ein Aufruf von push im Stack wirkt sich dagegen sofort auf den nchsten Aufruf von pop aus. Listing 4.32 zeigt die Implementierung eines Iterators auf einem binren Baum. Dieser Iterator gibt die Elemente in sortierter Reihenfolge zurck. Die Implementierung eines Baumiterators ist wesentlich aufwendiger als die eines Listeniterators. Der Grund dafr liegt darin, dass die Methode next den Baum auf hnliche Weise durchwandern muss wie die Methode print in Listing 4.17 fr die sortierte Ausgabe der Elemente, dabei aber keine Rekursion verwenden kann. Rekursion ist nicht mglich, weil
295
Listing 4.32: Implementierung eines sortierten Iterators ber binrem Baum public class GenTree<A extends Comparable<A>> implements Collection<A> { private TreeNode<A> root = null; public Iterator<A> iterator() { // neuer Iterator, iteriert return new TreeIter<A>(root); // in sortierter Reihenfolge } ... } class TreeIter<A extends Comparable<A>> implements Iterator<A> { private GenStack<TreeNode<A>> stack = new GenStack<TreeNode<A>>(); TreeIter(TreeNode<A> n) { // stack enthlt Pfad von Wurzel while (n != null) { // bis zu ganz linkem Blatt (top [Link](n); // Knoten mit kleinstem Element) n = [Link](); } } public A next() { // gib nchstes Element zurck TreeNode<A> result = [Link](n); // top: nchster Knoten if (result != null) { // noch nicht am Ende: TreeNode<A> n = [Link](); // rechts weiter -> while (n != null) { // top = kleinstes [Link](n); // Element rechts n = [Link](); // von result } return [Link](); } return null; } public boolean hasNext() { // weitere Elemente? return ![Link](); } } class TreeNode<A extends Comparable<A>> { // Erweiterung ntig private A elem; private ListNode<A> left, right; A getElem() { return elem; } // Getter fr elem ListNode<A> getLeft() { return left; } // Getter fr left ListNode<A> getRight() { return right; } // Getter fr right ... }
das Durchwandern nach jedem gefundenen Element abgebrochen werden muss und erst wieder beim nchsten Aufruf von next fortgesetzt werden
296
kann. Die aktuelle Position im Baum lsst sich, anders als bei der Liste, nicht in nur einer einfachen Variablen festhalten. Programmiersprachen verwenden einen fr den Programmierer nicht sichtbaren Stack um darin die Variablen geschachtelter Methodenaufrufe zu speichern, auch die von rekursiven Aufrufen. Daher ist ein Stack oft eine hilfreiche Datenstruktur um eine rekursive Methode in eine nicht-rekursive umzuwandeln. Wir nehmen den in Listing 4.31 implementierten Stack zu Hilfe. Auf dem Stack legen wir im Wesentlichen noch nicht besuchte Knoten des Baums ab, die den rekursiven Aufrufen von [Link]() in Listing 4.17 entsprechen. Fr Aufrufe von [Link]() brauchen wir keinen Stack, da sich dafr eine Situation sehr hnlich der mit der Liste ergibt. Im Konstruktor legen wir den ganzen Pfad (also alle beim Durchwandern besuchten Knoten) von der Baumwurzel bis zum am weitesten links stehenden Blattknoten auf den Stack. Das ist der Knoten mit dem kleinsten Element. Jeder Aufruf von next gibt das oberste Element am Stack zurck und legt danach den Pfad bis zum am weitesten links stehenden Knoten im rechten Teilbaum auf den Stack. Das ist das nchste Element. Falls es keinen rechten Teilbaum gibt, ist das nchste Element der Vorgnger im Baum, der als nchstes ganz oben am Stack liegt. Es gibt viele Mglichkeiten, einen Baum zu durchwandern. Statt einer sortierten Ausgabe knnten wir beispielsweise immer zuerst die Wurzel ausgeben, dann den linken und schlielich den rechten Teilbaum. Wir knnten auch zuerst die beiden Teilbume und erst dann die Wurzel ausgeben, oder zuerst die Wurzel, dann die Elemente eine Ebene unterhalb der Wurzel, dann jene zwei Ebenen darunter und so weiter. Egal welche Reihenfolge wir whlen, mit nur einer Variablen zur Speicherung des aktuellen Knotens kommen wir niemals aus. Wir brauchen entweder einen Stack, oder wir speichern in jedem Baumknoten auch den Vorgngerknoten, oder wir durchwandern den Baum mehrfach auf der Suche nach Knoten. Iteratoren mssen keine Reihenfolge vorschreiben. Meist wird eine bestimmte Reihenfolge eingehalten um den Iterator vielfltiger einsetzen zu knnen. Manchmal haben wir mehrere Iterator-Klassen mit unterschiedlichen Reihenfolgen auf derselben Container-Klasse implementiert, aber nur Instanzen von einer davon knnen ber die in Iterable<E> spezizierten Methode iterator erzeugt werden. Iteratoren sind als Abstraktionsmittel sehr wertvoll. Man kann alle Elemente eines Containers besuchen, ohne die Art des Containers kennen zu mssen. Weil Iteratoren hug eingesetzt werden, gibt es in Java eine spezielle Syntax fr for-Schleifen ber Containern hnlich den for-Schleifen
297
ber Arrays: In for(T v: c) . . . luft die Schleifenvariable v vom Typ T ber alle Elemente des Containers c, wobei c eine Instanz von Iterable<T> sein muss. Die Elemente werden ber den Iterator ausgelesen und in der vom Iterator vorgegebenen Reihenfolge abgearbeitet. Die Methode print in Listing 4.29 knnen wir krzer so schreiben:
Listing 4.33: Verwendung einer for-Schleife ber einem Container public void print() { for (String s: lines) { [Link](s); } }
Diese Denition hat genau dieselbe Semantik wie jene in Listing 4.29.
298
umfassen mehrere Tausend Klassen. Kaum jemand hat einen vollstndigen berblick darber. Glcklicherweise reicht die Kenntnis eines kleinen Teils der Klassen aus um fr viele praktisch auftretende Teilaufgaben angepasste Lsungen zu nden. Andererseits reicht es nicht, wenn man nur von der Existenz fertiger Lsungen wei. Man muss auch lernen, die fertigen Lsungen richtig einzusetzen. Hug ist das Erlernen des richtigen Einsatzes scheinbar aufwendiger, als selbst rasch eine neue Lsung zu entwickeln. Allerdings ist diese rasch entwickelte Lsung meist wenig durchdacht und daher fehleranfllig, was im Nachhinein zu einem viel hheren Aufwand fhren kann als angenommen. Wenn man eine hochwertige und inhaltlich gut passende Lsung fr eine Teilaufgabe gefunden hat, sollte man diese trotz hohem Einlernaufwand einsetzen. Unterschiedliche Modelle: Hinter jedem Lsungsansatz steckt ein bestimmtes gedankliches Modell. Nur selten passen die Modelle hinter einer fertigen Lsung und hinter dem restlichen Programm zufllig gut zusammen. Damit die Modelle bereinstimmen, mssen wir das Programm schon im Hinblick auf die Verwendung bestimmter Teile entwickeln. Mittlerweile sind zumindest die Standardbibliotheken so weit gereift, dass die Modelle hinter hug gemeinsam verwendeten fertigen Klassen gut zusammenpassen. Das ist keineswegs selbstverstndlich. Wenn das Modell hinter einer fertigen Lsung einer Teilaufgabe ganz und gar nicht mit unserem Programm zusammenpasst, ist es mglicherweise besser, auf die fertige Lsung zu verzichten, als das ganze Programm an die fertige Lsung anzupassen. Zustzlicher Code fr Anpassungen kann umfangreich und fehleranfllig sein. Mangelndes Vertrauen: Wer einen fertigen Programmteil einsetzt, muss darauf vertrauen, dass dieser Teil auf Dauer die Erwartungen erfllt. Dazu gehrt, dass der Programmteil frei von schdlichem oder sogar gefhrlichem Code ist und Fehler, die im Laufe der Zeit auftauchen, rasch beseitigt werden. Man muss auf den Hersteller des verwendeten Programmteils vertrauen. Mangelndes Vertrauen ist eine von mehreren Ursachen fr das sogenannte Not-Invented-Here-Syndrom, das unter anderem bewirkt, dass in anderen Gruppen oder Unternehmen entwickelte Programmteile deutlich seltener eingebunden werden als in der eigenen Gruppe oder dem eigenen Unternehmen entwickelte. Vertrauen kann man durch keine Technik erzwingen.
299
Ich kann es besser: Gelegentlich verzichtet man auf die Verwendung fertiger Teile, weil man glaubt, selbst eine bessere oder zumindest besser an die Aufgabe angepasste Lsung nden zu knnen. Tatschlich knnen eigene Lsungen kleiner und einfacher sein, was durchaus vorteilhaft ist. Allerdings liegt die Ursache dafr oft darin, dass in der eigenen Lsung auf bestimmte Sonderflle, die auftreten knnten, vergessen wurde und in Zukunft ntige Erweiterungen noch fehlen. Fertige Programmteile sind meist (aber leider nicht immer) sehr gut durchdacht, kmmern sich um alle Sonderflle und haben schon fr die am hugsten ntigen knftigen Erweiterungen vorgesorgt. Das liegt daran, dass diese Teile schon vielfach eingesetzt wurden und daraus gewonnene Erfahrungen in die Weiterentwicklung eingeossen sind. Dieser Entwicklungsvorsprung ist mit einer eigenen Lsung trotz besserer Anpassung an die Aufgabe nicht aufholbar. Gerade fr die wichtigsten Datenstrukturen bieten Standardbibliotheken (nicht nur fr Java) umfangreiche fertige Lsungen. Arrays sind ohnehin in Java integriert. Als Ausgangspunkt fr die Suche nach passenden Datenstrukturen in Java kann Collection<E> oder (noch umfangreicher) Iterable<E> dienen. Die wichtigsten Container-Klassen implementieren diese Interfaces. Dazu zhlen beispielsweise LinkedList<E> (umfangreiche Implementierung einer verketteten Liste), HashSet<E> (eine Hashtabelle), Stack<E> und TreeSet<E> (eine eziente Implementierung eines binren Baums, der nicht entartet sein kann). Daneben enthalten die Klassen Collections und Arrays eine Reihe von statischen Methoden, die zusammen mit Containern und Arrays verwendbar sind, beispielsweise zum Sortieren. Eigentlich gibt es keinen Grund, die Datenstrukturen, die wir in diesem Kapitel kennengelernt haben, selbst auszuprogrammieren abgesehen vom didaktischen Gesichtspunkt. Wir mssen ja auch interne Details von Datenstrukturen kennen. Im Vergleich zu den in Beispielen verwendeten Container-Klassen sind die von Java bereitgestellten zwar etwas umfangreicher, aber sehr klar strukturiert. Das liegt daran, dass jahrelange Erfahrungen im Umgang mit ihnen in die Entwicklung eingeossen sind. Dagegen sind die ursprnglich in den ersten Java-Versionen eingesetzten Container-Klassen nicht Teil der Collection<E> Typhierarchie und werden kaum noch verwendet. Heute werden keine ausgefallenen Operationen mehr untersttzt, und die verfgbaren Operationen sind sehr einheitlich gestaltet. Diese Eigenschaften braucht man um sich rasch in der Vielfalt der verfgbaren Daten-
300
strukturen zurechtzunden. Gerade die einheitliche Gestaltung lsst ein ganz bestimmtes Modell entstehen. Es orientiert sich an den verfgbaren Operationen. Beim Programmieren sollte man sich an dieses vorgegebene Modell halten, da es sonst schwierig wird, fertige Lsungen zu verwenden. Man braucht gewisse Erfahrung im Umgang mit Standardbibliotheken. Zusammenfassend kann man sagen, dass die Verwendung vorgefertigter Teile einerseits wichtig ist, man andererseits aber ohne Erfahrung damit kaum erfolgreich sein wird. Einerseits kann man sich sehr viel Arbeit ersparen und zugleich die Zuverlssigkeit erhhen. Andererseits ist die Verwendung fertiger Lsungen zu Beginn mit zustzlicher Arbeit verbunden. Man sollte nicht beliebige fertige Teile einbinden, sondern nur solche, die sich bewhrt haben und zu denen man Vertrauen hat. Unter diesen Voraussetzungen zahlt es sich aus, Programme so zu strukturieren, dass sie mit Modellen hinter fertigen Lsungen zusammenpassen. 4.6.2 Top-Down versus Bottom-Up Die Top-Down-Strategie versucht, Systeme anfangs auf einer abstrakten Ebene zu strukturieren und schrittweise mit Details anzureichern, bis alle Teile implementiert sind. Zuerst werden die wesentlichen Vorgnge identiziert und diese dann getrennt voneinander implementiert. Ein Beispiel soll diese Vorgehensweise demonstrieren: Wir entwickeln ein Programm, das Zahlen einliest und diese in sortierter Reihenfolge ausgibt. Einen Teil der Aufgabe haben wir bereits in Abschnitt 4.3.2 betrachtet. Nun wollen wir top-down vorgehen: Aus der Aufgabe ergibt sich ganz natrlich eine Gliederung in drei Vorgnge: Einlesen der Zahlen, Sortieren und Ausgeben. Die direkte Umsetzung knnte so aussehen:
Listing 4.34: Top-Down-Strategie: Einlesen, Sortieren, Ausgeben public static void main(String[] args) { List<Integer> nums = readNums(); [Link](nums); print(nums); }
Als Container haben wir eine Instanz von List<Integer> gewhlt, da die Klasse Collections dafr eine statische Methode zum Sortieren enthlt. Um das Sortieren brauchen wir uns daher nicht weiter zu kmmern. Das Einlesen knnen wir untergliedern in das Erzeugen eines Scanners, das Erzeugen der Datenstruktur und eine Schleife zum Einlesen der Zahlen:
301
Listing 4.35: Top-Down-Strategie: Einlesen von Zahlen private static List<Integer> readNums() { Scanner sc = new Scanner([Link]); List<Integer> nums = new LinkedList<Integer>(); while ([Link]()) [Link]([Link]()); return nums; }
In einem weiteren Schritt sollten wir diese Methode durch Ausgabe von Eingabeauorderungen und Fehlermeldungen bei falschen Eingaben benutzerfreundlicher gestalten. Darauf verzichten wir hier um nicht vom Wesentlichen hinter der Top-Down-Strategie abzulenken. Schlielich brauchen wir auch noch eine Methode zum Ausgeben:
Listing 4.36: Top-Down-Strategie: Ausgeben private static void print(List<Integer> nums) { for (int i: nums) [Link](i); }
Wie wir sehen, fhrt die Top-Down-Strategie eher zu traditionellen imperativen statt zu objektorientierten Programmen; alle Methoden sind statisch, und wir knnen sie in eine beliebige Klasse geben. Eine Untergliederung der Aufgabe fhrt zur nchsten, und nach kurzer Zeit ist die Aufgabe gelst. Allerdings ist die dabei entstehende Programmstruktur meist nicht optimal: Durch die frhe Unterteilung bleiben Querverbindungen zwischen den Teilen unerkannt. Oft sind fertige Lsungsteile nicht einsetzbar, weil sie nicht mit der Gliederung bereinstimmen. Die Bottom-Up-Strategie geht genau umgekehrt vor. Man sucht zuerst nach einzelnen bereits gelsten Teilen, die wahrscheinlich gebraucht werden. Dann versucht man, daraus grere Teile zu formen, bis die eigentliche Aufgabe gelst ist. Es ist einfach zu erkennen, dass eine sortierte Datenstruktur die Lsung der Beispielaufgabe vereinfacht. Wir brauchen einen Container, der das Einlesen und Ausgeben untersttzt siehe Listing 4.37. Damit sind wir schon fast fertig. Es fehlt nur noch die Methode main wie in Listing 4.38. Durch geschickte Auswahl der Teile, die bottom-up erstellt werden, knnen bereits vorhandene Programmteile gut genutzt werden. Es ist leich-
302
Listing 4.37: Bottom-Up-Strategie: Wichtige Teile zuerst import [Link]; import [Link]; public class SortedNums { private GenTree<Integer> nums = new GenTree<Integer>(); public void readFrom(Scanner in) { while ([Link]()) [Link]([Link]()); } public void printTo(PrintStream out) { for (int i: nums) [Link](i); } }
Listing 4.38: Bottom-Up-Strategie: main kommt am Ende public static void main(String[] args) { SortedNums nums = new SortedNums(); [Link](new Scanner([Link])); [Link]([Link]); }
ter, vorhandene Beziehungen zwischen einzelnen Teilen zu erkennen und in schn strukturierte Klassen zu integrieren. Allerdings kann es bei greren Aufgaben passieren, dass wir uns verlaufen. Dann schreiben wir Klassen, die am Ende nicht gebraucht werden, weil sie die Lsung der Gesamtaufgabe nicht vereinfachen. Mit der Top-Down-Strategie passiert das nicht. Die Top-Down- und die Bottom-Up-Strategie knnen einander ergnzen. In vielen Fllen ist eine Kombination aus beiden Strategien sinnvoll. Klassen zur Lsung von Teilaufgaben, die sicher gebraucht werden, knnen wir schon einmal bottom-up erstellen, noch bevor die Gliederung des Gesamtsystems bekannt ist. Die grobe Gliederung des Systems nehmen wir jedoch top-down vor, damit wir uns nicht so leicht verlaufen. Bei der Gliederung lassen wir uns bis zu einem gewissen Grad von den bereits bottom-up erstellten Teilen leiten. Abstrakte Gliederungen entstehen eher top-down, konkrete Realisierungen bottom-up, und die beiden Strategien treen sich irgendwo in der Mitte des Abstraktionsgrads.
303
4.6.3 Schrittweise Verfeinerung Strategien wie Top-Down und Bottom-Up sind gut geeignet um klar umrissene, eher kleine Programmieraufgaben zu lsen. In der Praxis haben wir es oft mit riesengroen Aufgaben zu tun, die unmglich in ihrer Gesamtheit durchschaubar sind. Prinzipiell knnten wir zwar trotzdem top-down vorgehen um Aufgaben in kleinere Teile zu zerlegen, aber das hat sich nicht bewhrt. Wir bekommen viel zu spt Rckmeldungen darber, ob ein gewhlter Lsungsansatz funktioniert. Das heit, wir mssen ein riesiges Programm entwerfen und implementieren, bevor wir es testen knnen. Tests werden ziemlich sicher schwerwiegende Mngel aufzeigen. Deren Ursachen sind alleine schon aufgrund der Gre des Programms nur schwer zu nden und noch schwerer zu beseitigen. Manchmal stellt sich erst beim Testen heraus, dass der generelle Lsungsansatz falsch ist und umfangreiche Teile des Systems neu entwickelt werden mssen. So manches Softwareprojekt ist deswegen schon gescheitert. Die Strategie der schrittweisen Verfeinerung verspricht, dieses Problem in den Gri zu bekommen. Dabei lsen wir zu Beginn nur einen kleinen, berschaubaren Teil der Programmieraufgabe. Fr diesen Teil durchlaufen wir alle Schritte der Softwareentwicklung von der Analyse ber den Entwurf und die Implementierung bis zum Verizieren, Testen und Validieren. Bei geeigneter Wahl des Programmteils sind Top-Down- und Bottom-UpStrategien oft gut anwendbar. Vor allem durch die letzten Schritte bekommen wir gute Rckmeldungen ber die bisherige Qualitt des Programms. Solche Informationen nutzen wir bei der Weiterentwicklung. Wir erweitern unsere bisherige Lsung entsprechend der Aufgabe sowie den Rckmeldungen und fhren fr jede Erweiterung alle Softwareentwicklungsschritte durch. In den meisten Fllen wird sich unsere Vorstellung darber, wie die Aufgabenstellung zu interpretieren ist, im Laufe der Zeit und mit den gewonnenen Erfahrungen stndig verschieben. Auch die Aufgabe selbst wird sich durch neue Wnsche knftiger Anwender aufgrund der Erfahrungen mit den ersten Programmversionen ndern. Durch die schrittweise Verfeinerung knnen wir bei Bedarf recht exibel auf solche nderungen reagieren. Die Vorteile der schrittweisen Verfeinerung zeigen sich erst bei der Lsung groer Aufgaben. Deshalb wirkt jedes berschaubare Beispiel dafr eher geknstelt. Wir wenden diese Strategie dennoch in zwei Varianten auf obiges Beispiel (Einlesen und sortiertes Ausgeben von Zahlen) an, ohne jedoch konkreten Programmcode vorzugeben:
304
Zuerst entwickeln wir ein Programm, das nur Zahlen einliest und unsortiert wieder ausgibt.3 Dieses Programm zeigen wir den Anwendern und bercksichtigen in einem nchsten Verfeinerungsschritt die Rckmeldungen hinsichtlich der Benutzerschnittstelle. Schlielich bauen wir in einem letzten Schritt auch das Sortieren ein. In einer ganz anderen Art der Aufteilung beschftigen wir uns zuerst mit dem Sortieren und entwickeln ein Programm, das Zufallszahlen sortiert und zum Testen ausgibt. Erst wenn das Sortieren funktioniert, erweitern wir das Programm um Benutzerschnittstellen (vor allem fr das Einlesen), holen Rckmeldungen von den Benutzern ein und bercksichtigen diese in einem letzten Schritt. Es ist schwer zu sagen, welche der hier skizzierten Vorgehensweisen besser ist. Eine Faustregel besagt, dass man zuerst immer jene Teile einer Aufgabe lsen soll, die am schwierigsten sind und auf die es am ehesten ankommt. Die erste Variante wird man whlen, wenn man die Interaktion mit den Benutzern fr den schwierigsten und wichtigsten Teil des Programms hlt, die zweite Variante, wenn man das technische Problem des Sortierens fr schwierig und entscheidend hlt. Diese Schwerpunktsetzung wird im fertigen Programm sichtbar sein. Die erste Variante fhrt wahrscheinlich zu einer ausgefeilten Benutzerschnittstelle, aber einem eher mittelmigen Sortierverfahren. In der zweiten Variante ist das Sortieren im Gegensatz zur Benutzerschnittstelle vermutlich viel ausgereifter. Auch die Strategie der schrittweisen Verfeinerung kann zu Problemen fhren: Bei einer Erweiterung des Programms stellt sich manchmal heraus, dass eine Datenstruktur oder die Faktorisierung des Programms fr den neuen Programmteil nicht oder nur unzureichend geeignet ist. In solchen Fllen muss man die Datenstruktur oder Faktorisierung ndern. Letzteres nennt sich Refaktorisierung ein eigener Name, weil Refaktorisierungen so hug ntig sind. Solche nderungen knnen aufwendig sein. Trotzdem sollte man sie so bald wie mglich machen, da die nderungen umso aufwendiger sind, je spter man sie macht. In Extremfllen kann es passieren, dass in fast jedem Schritt solche nderungen ntig sind, und die eigentliche Erweiterung des Programms nur sehr langsam oder gar nicht voranschreitet. Daher ist nur schwer planbar, wie lange es dauern wird, bis
3
Bei Verwendung eines binren Baums ist diese Vorgehensweise nicht sehr sinnvoll, da sich eine sortierte Ausgabe praktisch von alleine ergibt. Aber man kann sich trotzdem vorstellen, wie man einen ersten Teil der Aufgabenstellung whlt.
305
das Programm fertig ist, und welche Kosten die Fertigstellung verschlingen wird. Trotzdem hat sich die schrittweise Verfeinerung bewhrt und wird in unzhligen Softwareprojekten praktisch eingesetzt. Softwareentwicklungsprozesse auf dem Prinzip der schrittweisen Verfeinerung nennt man inkrementelle Prozesse. Dieser Name ist naheliegend. Heute spricht man hug von agilen Prozessen, die Versuchen, die Softwareentwicklung exibler und schlanker (mit weniger brokratischem Aufwand) zu gestalten als dies mit traditionellen Entwicklungsprozessen mglich ist. Man mchte sich mehr auf die eigentlichen Ziele konzentrieren. Inkrementelle Prozesse bilden eine gute Basis fr agile Prozesse.
306
Die Fhigkeit zum selbstndigen Entwickeln von Datenstrukturen und Algorithmen bzw. das selbstndige Kombinieren bekannter Datenstrukturen und Algorithmen zu greren Einheiten kennzeichnet einen ganz wichtigen Sprung beim Programmierenlernen. Sobald man das beherrscht, kann man viele Programmieraufgaben meistern. Das kann man mit dem Erlernen des Lesens und Schreibens vergleichen: Ein gutes Verstndnis der Syntax und Semantik grundlegender Sprachkonstrukte sowie der pragmatischen Anwendung dieser Konstrukte ist eine Grundvoraussetzung fr das Programmieren, so wie die Kenntnis einzelner Buchstaben und deren Zusammensetzung zu Wrtern bzw. einfachen Wortgruppen eine Grundvoraussetzung fr das Lesen und Schreiben ist. Aber es braucht mehr. Die Strukturen in Programmen werden hauptschlich als Algorithmen und Datenstrukturen sichtbar. Man muss diese Strukturen begreifen und eigene Strukturen entwickeln knnen, so wie man beim Lesen die Gedanken hinter den Stzen verstehen und beim Schreiben eigene Gedanken in Stze kleiden muss. Erst wenn man auch das beherrscht, kann man ssig Programmieren bzw. Lesen und Schreiben lernen und einen eigenen Programmier- bzw. Schreibstil entwickeln. 4.7.1 Kontrollfragen Was versteht man unter Algorithmen und Datenstrukturen, und wie hngen diese beiden Begrie zusammen? Unter welchen Bedingungen sind zwei Algorithmen bzw. Datenstrukturen gleich? Wann sind sie es nicht? Nennen Sie fnf unterschiedliche Datenstrukturen und charakterisieren Sie sie. Wozu dienen Lsungsstrategien? Warum sind so viele Datenstrukturen rekursiv? Welche Zugrisoperationen haben Stacks, verkettete Listen und binre Bume blicherweise? Welche charakteristischen Merkmale zeichnen eine verkettete Liste und einen binren Baum aus? Wie hngen Datenstrukturen mit gerichteten Graphen zusammen?
307
Wodurch unterscheiden sich rekursive Methoden von entsprechenden iterativen (und nicht-rekursiven)? Was haben rekursive Datenstrukturen und rekursive Methoden mit vollstndiger Induktion gemeinsam? Wie kann man den Aufwand eines Algorithmus abschtzen? Wofr stehen O(1), O(log(n)), O(n), O(n log(n)), O(n2 ) und O(2n )? Wie wirkt sich eine Verdopplung oder Verhundertfachung von n aus? Wieso kann man konstante Faktoren bei der Aufwandsabschtzung einfach ignorieren? Wie hoch ist der Aufwand fr das Einfgen bzw. das Suchen in der verketteten Liste, im binren Baum sowie in der Hashtabelle im Durchschnitt und im schlechtesten Fall? Was ist der jeweils schlechteste Fall und wann tritt er ein? Wie funktionieren Bubblesort, Mergesort und Quicksort? Wie hoch ist der Aufwand dafr im Durchschnitt und im schlechtesten Fall? Was ist eine binre Suche? Was unterscheidet generische von nicht-generischen Klassen? Was unterscheidet einen Typ von einem Typparameter? Kann man Typen und Typparameter gleich verwenden? Wie kann man primitive Typen wie int als Elementtypen in generischen Containern verwenden? Wozu dienen Schranken bei gebundener Generizitt? Welchen speziellen Zweck hat rekursive gebundene Generizitt? Inwiefern hneln sich Untertypbeziehungen und Generizitt? Wodurch unterscheiden sie sich in ihrer Anwendbarkeit? Was sind und warum verwendet man Iteratoren? Welche Schwierigkeiten treten bei der Implementierung von Iteratoren im Zusammenhang mit Rekursion auf? Wie lst man sie?
308
Durch welches spezielle Sprachkonstrukt untersttzt Java die Verwendung von Iteratoren? Wodurch wird die Verwendung fertiger Programmteile erschwert? Wie kann man den Ursachen dafr begegnen? Welche Vor- und Nachteile hat die Top-Down-Strategie gegenber der Bottom-Up-Strategie? Wie lassen sich diese beiden Strategien miteinander kombinieren? Fr welche Aufgaben bietet sich die schrittweise Verfeinerung an? Mit welchen Teilaufgaben sollte man bei schrittweiser Verfeinerung beginnen? Warum ist das so? Welche Vorteile und Schwierigkeiten knnen sich aus der schrittweisen Verfeinerung ergeben?
309
310
5 Qualittssicherung
Zahlreiche Faktoren beeinussen die Qualitt von Programmen siehe Abschnitt 1.6. Hohe Qualittsstandards sind nur zu erreichen, wenn die Programmkonstruktion verschiedene Manahmen zur Qualittssicherung einschliet. Das beginnt schon bei der Spezikation einer Programmieraufgabe. Ein bedeutendes Kriterium ist die gute Verstndlichkeit der Spezikation und des Programms aus statischer Sicht. Verstndlichkeit trgt wesentlich zur Fehlervermeidung bei. Zustzlich mssen wir die Qualitt durch geeignetes Testen berprfen, nicht nur am Ende, sondern wiederholt whrend der Programmkonstruktion. Wo ein statisches Programmverstndnis beispielsweise zur Feststellung von Fehlerursachen nicht ausreicht, muss der dynamische Programmablauf im Detail nachvollziehbar sein. Ein sorgfltiger Umgang mit Ausnahmesituationen trgt ebenso zur Qualittssicherung bei wie verschiedene Formen der Validierung.
5.1 Spezikationen
Bereits in Abschnitt 1.6.4 haben wir Mglichkeiten gesehen, wie man eine Programmieraufgabe also die gewnschten Eigenschaften der zu erstellenden Software spezizieren kann. Von der Form der Spezikation hngt unter anderem ab, wie einfach es ist, die Spezikation und das Programm statisch zu verstehen, das bereinstimmen von Spezikation und Programm zu verizieren und das Programm zu testen. Bei der Softwareentwicklung erstellt man in der Analysephase eine Anforderungsdokumentation und daraus ein Design und dessen Implementierung. Die Anforderungsdokumentation ist eine Form der Programmspezikation. Wir verwenden diesen Begri hier jedoch etwas allgemeiner: Jede Form einer mehr oder weniger rigorosen Beschreibung eines Systems ist eine Spezikation. Dazu zhlt auch die Beschreibung des Designs und die Implementierung selbst. Vor allem zhlen auch Zusicherungen dazu.
311
5 Qualittssicherung
Die Genauigkeit der Spezikation eines Systems nimmt whrend dessen Entwicklung stndig zu: Am Anfang steht oft nur eine Sammlung informeller Beschreibungen von Anwendungsfllen. Mit der Zeit kommt Struktur in diese Sammlung, die teilweise auch formalisierbar ist, und Beschreibungen der Anwendungsflle werden konkret. Zusammen mit dieser Struktur entwickelt sich eine Faktorisierung des Systems. Alle Teile des Systems werden, wie die Anwendungsflle, immer genauer speziziert. Sptestens durch die Implementierung wird die Spezikation formal. Durch die Verikation stellen wir sicher, dass die Implementierung mit einer frheren Form der Spezikation des Systems bereinstimmt. 5.1.1 Anforderungsspezikation und Anwendungsflle Requirements-Engineering ist ein Zweig der Informatik. Er beschftigt sich mit der Entwicklung und Verwaltung der Anforderungen an ein System. Zahlreiche Verfahren werden eingesetzt um zu einer mglichst klaren Anforderungsspezikation zu kommen. Hier beschrnken wir uns auf einen einfachen Ansatz: Wir beobachten knftige Anwender(innen) eines Systems tatschlich oder in unserer Vorstellung und notieren alle Ttigkeiten, die zu erledigen sind. Das Ergebnis ist eine Sammlung von Anwendungsfllen. Wir ordnen sie und bringen Struktur hinein, und schon haben wir eine Spezikation des Systems. Als Beispiel entwickeln wir ein Werkzeug zur Abschtzung der Komplexitt eines Java-Programms. Es gibt nur einen Anwendungsfall: Man speziziert beim Programmstart die Namen beliebig vieler Java-Quellcodedateien als Kommandozeilenargumente. Am Bildschirm erscheinen folgende Daten (oder eine Fehlermeldung, falls eine Datei nicht existiert): Anzahl der in den Dateien denierten Klassen durchschnittliche Anzahl der nichtleeren Zeilen pro Klasse durchschnittliche Anzahl der Kommentarzeilen pro Klasse Diesen Anwendungsfall kann man schon als Anforderungsspezikation des Werkzeugs betrachten. Es wird nur beschrieben, was man tut oder erwartet, aber nicht, wie die Aufgabe gelst werden soll. Bei nherer Betrachtung stellt sich heraus, dass einige Details dieser Spezikation nicht klar sind oder vielleicht sogar anders gedacht waren, als zu lesen ist. Wir treen einige Annahmen zur Klarstellung der Aufgabe:
312
5.1 Spezikationen
Das WortKlassen sollte durch Klassen bzw. Interfaces ersetzt werden, da die Ergebnisse damit an Aussagekraft gewinnen. Wir gehen davon aus, dass Interfaces ohne Absicht unerwhnt blieben. Wir betrachten alle Zeilen als leer, die nur White-Space enthalten. Gezhlt werden sollen also nur Zeilen, die auch etwas anderes als Leerzeichen und Tabulatorzeichen enthalten. Als Kommentarzeilen betrachten wir Zeilen, die entweder //, /* oder */ auerhalb von Strings enthalten. Auch nichtleere Zeilen zwischen /* und */ zhlen zu den Kommentarzeilen. Programmaufrufe ohne Dateien sollen zu Fehlermeldungen fhren. Nun stellt sich die Frage, was eigentlich die richtige Anforderungsspezikation ist, die ursprngliche oder die verbesserte Spezikation. Die Antwort darauf hngt von der Rolle ab, welche die Anforderungsspezikation spielt. Oft handelt es sich dabei um einen Bestandteil eines Vertrages zwischen einem Auftraggeber und Auftragnehmer. In diesem Fall ist die Sache klar: Vertrge mssen eingehalten werden, auch wenn zum Zeitpunkt der Vertragserstellung noch viele Details oen sind. Wir knnen die Spezikation nicht ohne Weiteres durch Hinzunahme des ersten sowie des letzten Punktes obiger Annahmen ergnzen, da dies mit einer inhaltlichen nderung einhergehen wrde. Allerdings ist es durchaus mglich, dass sich Auftraggeber und Auftragnehmer auch noch nach Abschluss des Vertrages auf solche Vertragsnderungen einigen. Eine Ergnzung um den zweiten und dritten Punkt wre dagegen problemlos mglich, weil nur unklare Begrie deutlicher gemacht werden. Natrlich knnen Auftraggeber und Auftragnehmer auch gemeinsam eine genauere Klrung der Begrie erarbeiten, ein neuer Vertrag entsteht dadurch nicht notwendigerweise. hnlich verhlt es sich bei Programmieraufgaben, die im Rahmen einer bung zu lsen sind. Man muss sich an vorgegebene Spezikationen halten, auch wenn man bei der Bearbeitung bemerkt, dass Verbesserungsmglichkeiten bestehen. In Extremfllen kann man nachfragen, wenn die Aufgabenstellung unlogisch erscheint. Was nicht im Detail genau festgelegt ist, bietet einen Interpretationsspielraum, den man ausnutzen kann. Es ist der Normalfall, wenn Spezikationen im Laufe der Zeit genauer werden. Daher gestaltet man Vertrge ber die Entwicklung von Software oft auch derart, dass zwar klare Ziele festgelegt werden, aber Details der Anforderungsspezikation oen bleiben. In diesen Fllen ist es wenig
313
5 Qualittssicherung
sinnvoll, nur die groben Vorgaben in die Verikation einzubeziehen. Man wird whrend der Entwicklung genauere Anforderungsspezikationen fr alle Teile des Systems erstellen und als Grundlage fr die Verikation verwenden. Jede Anforderungsspezikation ist ein Vertrag, auch wenn der Vertrag nicht notwendigerweise zwischen Firmen oder Personen als Auftraggeber und Auftragnehmer abgeschlossen sein muss. In der Programmkonstruktion verwenden wir Vertrge ganz allgemein zur Festlegung von Anforderungsspezikationen auf allen Ebenen, vom gesamten Programm ber Objekte (reprsentiert durch Interfaces und Klassen) bis zu einzelnen Methoden. Jede Beschreibung einer Schnittstelle wird als Vertrag verstanden. Die formalen Teile solcher Vertrge zwischen Objekten werden in Java beispielsweise durch Interfaces und Klassen festgelegt. Vertragspartner sind Objekte einerseits die Instanzen der Typen, welche die Methoden beschreiben, andererseits Objekte, die diese Methoden aufrufen. Die Objekte, welche die Methoden bereitstellen, spielen die Rolle eines Servers (Anbieters von Dienstleistungen bzw. Auftragnehmers), whrend die Objekte, welche die Methoden aufrufen, die Rolle eines Clients (Kunden oder Auftraggebers) spielen. Vertragsbestandteile sind die Namen der Methoden, die Typen der bergebenen Argumente sowie die Typen der Ergebnisse. Allerdings reichen diese formalen Vertragsbestandteile nicht aus, um das Verhltnis zwischen Client und Server vollstndig zu spezizieren. Daher enthalten Interfaces und Klassen in der Regel zustzlich informelle Spezikationen in Form von Kommentaren. Obwohl es sich nur um Kommentare handelt, sind sie als wichtige Vertragsbestandteile Ernst zu nehmen. 5.1.2 Design-by-Contract Zusicherungen auf Methoden haben wir in Abschnitt 1.5.4 kennen gelernt und in Abschnitt 2.4.4 vertieft. Wir unterschieden Vorbedingungen, die bereits vor Ausfhrung einer Methode erfllt sein mssen, von Nachbedingungen, die erst nach der Methodenausfhrung erfllt sein mssen. In Abschnitt 3.5 haben wir gesehen, wie die Zusicherungen auf Objekte bertragen werden knnen und Invarianten quasi unvernderliche Eigenschaften beschreiben. Dabei ging es vor allem um die Rolle von Zusicherungen als Kommunikationsmittel zwischen Personen. Nun wollen wir diesen Blickwinkel erweitern und Zusicherungen als Bestandteile eines Vertrags zwischen einem Client und Server betrachten (der bei der Entwicklung der Klassen fr Client und Server einzuhalten ist). Generell sieht ein sol-
314
5.1 Spezikationen
cher Softwarevertrag (also ein Vertrag als Teil der Software, nicht ber Softwareerstellung, Softwarelizenzen, etc.) folgendermaen aus: Der Client kann durch Senden einer Nachricht an den Server einen angebotenen Dienst in Anspruch nehmen, wenn die Schnittstelle des Servers eine entsprechende Methode beschreibt, jeder Argumenttyp in der Nachricht Untertyp des entsprechenden formalen Parametertyps der Methode ist und alle Vorbedingungen der Methode erfllt sind. Der Server wird unter diesen Bedingungen die Methode ausfhren und sicherstellen, dass unmittelbar nach Ausfhrung eine Instanz des Ergebnistyps als Antwort zurckkommt und alle Nachbedingungen der Methode und Invarianten des Servers erfllt sind. Entsprechend dem Softwarevertrag kann sich der Server darauf verlassen, dass der Client fr die Einhaltung der Vorbedingungen vor jeder Ausfhrung einer Methode sorgt. Der Client kann sich darauf verlassen, dass der Server fr die Einhaltung der Nachbedingungen und Invarianten am Ende der Ausfhrung einer Methode sorgt. Es ist also klar geregelt, wer wofr zustndig ist und worauf man sich verlassen darf. Genau wegen dieser klaren Regelungen sind Zusicherungen fr die Spezikation in objektorientierten Programmen so wichtig, und man gibt den Zusicherungen eine zentrale Rolle im Entwurf von Klassen und ganzen Systemen. Die Vorgehensweise, bei der man Klassen und Systeme durch solche Vertrge beschreibt, nennt man Design-by-Contract. Wie der Name schon andeutet, gehrt die Erstellung solcher Vertrge zur Entwurfsphase, nicht zur Analysephase. Die Spezikationen mssen eindeutig und recht genau sein, damit die Vertrge ihren Zweck erfllen knnen. Dieser Aspekt unterscheidet sie von den meist eher allgemein gehaltenen Spezikationen, die als Anforderungsspezikationen eine Grenze zwischen der Analyseund Entwurfsphase bilden. Oft entstehen die wichtigsten und zentralsten Vertrge fr Design-by-Contract zu dem Zeitpunkt, an dem zusammen mit der Faktorisierung eines Systems Interfaces entwickelt werden. Viele weitere, eher weniger zentrale Vertrge entstehen gleichzeitig mit der Implementierung von Klassen. Wenn wir Interfaces und Klassen entwickeln, sind nur wir selbst auch fr die Entwicklung der Vertrge verantwortlich. Entsprechend Design-by-Contract soll jedes Stckchen Code, das wir
315
5 Qualittssicherung
schreiben, einem Vertrag entsprechen. Dadurch gibt Design-by-Contract ein Denkmuster vor, das uns beim Entwickeln von Code leitet: Wir mssen stets daran denken, welche Vertragsbestandteile die Methoden, die wir schreiben, erfllen mssen, aber auch, was die Methoden, die wir aufrufen, von Clients erwarten. Andererseits drfen wir ohne weitere Prfung davon ausgehen, dass die Vorbedingungen der Methoden, die wir schreiben, erfllt sind, und aufgerufene Methoden das machen, was ihre Nachbedingungen und die Invarianten versprechen. Design-by-Contract verbessert vor allem das statische Verstndnis des Programmcodes. Man wei, was man sich vom Aufruf einer Methode erwarten kann, ohne den dabei ausgefhrten Code nachvollziehen zu mssen. Design-by-Contract gibt klare Richtlinien vor, in welcher Beziehung Zusicherungen in Unter- und Obertypen zueinander stehen mssen, damit Vertrge erfllbar sind: Vorbedingungen in Untertypen drfen schwcher, aber nicht strker als entsprechende Bedingungen in Obertypen sein. Wenn eine Vorbedingung im Obertyp beispielsweise x > 0 lautet, darf die entsprechende Bedingung im Untertyp x >= 0 sein. Im Untertyp steht die Verknpfung beider Bedingungen mit ODER. Nachbedingungen und Invarianten in Untertypen drfen strker, jedoch nicht schwcher als die Bedingungen in Obertypen sein. Wenn eine Nachbedingung oder Invariante im Obertyp z.B. x >= 0 lautet, darf die entsprechende Bedingung im Untertyp x > 0 sein. Im Untertyp steht die Verknpfung beider Bedingungen mit UND. Diese Beziehungen stellen sicher, dass sich eine Instanz eines Untertyps so verhlt, wie man es sich von einer Instanz des Obertyps erwartet. ber den Vergleich von Zusicherungen im Unter- und Obertyp wird daher die in Kapitel 3 aufgestellte Forderung nach gleichem Verhalten erfllt, wobei die Spezikation im Untertyp trotzdem genauer sein kann als die im Obertyp. Zahlreiche Beispiele fr Zusicherungen nden wir in den Beschreibungen der Java APIs (Application-Programmer-Interfaces).1 Fast alle Zusicherungen sind als beschreibende Texte formuliert, ohne klare Unterscheidung zwischen Vor- und Nachbedingungen sowie Invarianten. Inhalte bestimmen, mit welcher Art von Zusicherung wir es zu tun haben:
1
Siehe beispielsweise die Beschreibungen der Klassen und Interfaces der Java Standard Edition 6 unter [Link]
316
5.1 Spezikationen
Einschrnkungen auf formalen Parametern sowie alles, um das sich Aufrufer von Methoden kmmern mssen, sind Vorbedingungen. Beschreibungen unvernderlicher Eigenschaften von Objektzustnden stellen Invarianten dar. Alles andere sind Nachbedingungen. Sie beschreiben, was die Methoden tun, und machen meist den Groteil der Zusicherungen aus. Fr die Einhaltung von Nachbedingungen und Invarianten ist der Server zustndig. Bei Invarianten ergibt sich das Problem, dass der Server mglicherweise keine vollstndige Kontrolle ber den Zustand des Objekts hat und die Invarianten deswegen gar nicht zusichern kann. Das ist der Fall, wenn Objektvariablen nicht nur von den Methoden des Objekts verndert werden, sondern auch von anderen Methoden. Die anderen Methoden knnten Invarianten verletzen, die sie gar nicht kennen. Aus diesem Grund sollte man niemals schreibend auf Objektvariablen eines anderen Objekts zugreifen. Ausnahmen von dieser Regel kann man nur machen, wenn man (beispielsweise bei privaten Variablen eines anderen Objekts derselben Klasse) die Zusicherungen des anderen Objekts genau kennt und einhlt. Deshalb sollten Objektvariablen als private deklariert werden. Java untersttzt assert-Anweisungen fr berprfte Zusicherungen. Fr Vor- und Nachbedingungen sowie Invarianten spielen berprfte Zusicherungen praktisch keine Rolle, weil viele der blicherweise im Text ausgedrckten Eigenschaften nur sehr umstndlich formalisierbar sind. Es liegt an uns, beim Programmieren auf die Einhaltung der Bedingungen zu achten. Einige Programmiersprachen wie Eiel oder D bieten viel weiter gehende Sprachuntersttzung fr berprfte Zusicherungen an. Aber auch in diesen Sprachen kann man nicht alle Zusicherungen in der Sprache ausdrcken, die wir gerne ausdrcken wrden. Auerdem ist es zu spt, wenn falsche Zusicherungen erst zur Laufzeit erkannt werden. Eigentlich wollen wir solche Fehler statisch erkennen knnen. 5.1.3 Abstraktion und Intuition So manche Bedingung lsst sich statt als Zusicherung auch als Typ ausdrcken. Das hat den Vorteil, dass der Compiler die Kompatibilitt der Typen zueinander statisch berprft und einen Fehler meldet, wenn Typen nicht zusammenpassen. Kommentare knnen altern, also nach Programm-
317
5 Qualittssicherung
Listing 5.1: Berechnung des Medians in sortiertem Array // gib mittlere Zahl in nums zurck; nums sortiert public static int median(int[] nums) { return nums[[Link] / 2]; }
Listing 5.2: Eigener Typ fr sortiertes Array ganzer Zahlen 1 import [Link] 2 public class SortedIntArray { 3 private int[] elems; // elems bleibt stets sortiert 4 public SortedIntArray(int[] e) { 5 elems = [Link] (e, [Link]); 6 [Link](elems); 7 } 8 public int median() { // gib mittlere Zahl zurck 9 return elems[[Link] / 2]; 10 } 11 public boolean member(int x) { // x enthalten? 12 ... /* binre Suche */ 13 } 14 }
nderungen pltzlich nicht mehr stimmen. Mit Typen kann das in einem compilierbaren Programm nicht passieren. Betrachten wir ein Beispiel dafr. Die statische Methode in Listing 5.1 soll den Median, also die Zahl mit dem mittleren Wert in einem Array ermitteln. Die Vorbedingung nums sortiert stellt sicher, dass die Nachbedingung gib mittlere Zahl in nums zurck eingehalten wird. Trotzdem ist diese Lsung nicht sehr befriedigend. Wird median unter Verletzung der Vorbedingung mit einem unsortierten Array aufgerufen, wird nur irgendeine zufllige Zahl im Array zurckgegeben. Solche Fehler sind schwer zu nden, da der falsche Aufruf berall erfolgen kann. berprfte Zusicherungen sind hier kaum sinnvoll einsetzbar, da hug wiederholte berprfungen, ob ein Array sortiert ist, ziemlich aufwendig wren. Listing 5.2 zeigt einen Ansatz zur Lsung dieses Problems: Wir verpacken das Array in ein eigenes Objekt und schrnken Zugrie auf das Array so ein, dass die gewnschte Eigenschaft (Sortiertheit) stets erhal-
318
5.1 Spezikationen
ten bleibt. Das im Konstruktor bergebene Array wird vor dem Sortieren kopiert um zu verhindern, dass es von auerhalb der neuen Instanz von SortedIntArray verndert werden kann. Eine Invariante auf elems stellt sicher, dass das Array stets sortiert bleibt. Im Gegensatz zur Lsung mittels statischer Methode kennen wir alle Stellen im Programm, an denen die Zusicherung mglicherweise verletzt werden knnte, da elems nur innerhalb der Klasse sichtbar ist. Neben der Methode median knnen auch andere Methoden in der Klasse sinnvoll sein, die auf ein sortiertes Array angewiesen sind, beispielsweise eine binre Suche. Auch Methoden zum ndern des Arrays wren sinnvoll; sie mssen nur garantieren, dass das Array nach jeder nderung noch immer sortiert ist. Um den Median berechnen zu knnen, mssen wir mit diesem Lsungsansatz eine Instanz von SortedIntArray erzeugen. Der Compiler garantiert, dass median ohne ein solches Objekt nicht aufrufbar ist. Auf diese Weise hilft der Compiler, die Einhaltung der Invariante sicherzustellen. Statt einem Typ int[] zusammen mit einer Zusicherung Array ist sortiert verwenden wir an vielen Stellen im Programm einfach nur den Typ SortedIntArray ohne Zusicherung. Das ist einfacher und sicherer. Bei nherer Betrachtung stellen wir fest, dass SortedIntArray eine Abstraktion darstellt, also eine abstrakte Maschine mit bestimmten Eigenschaften. Wenn wir viele Zusicherungen brauchen, um das Gewnschte auszudrcken, ist das ein Hinweis darauf, dass sich das Programm durch Einfhrung neuer Klassen verbessern lsst. Diese Klassen kapseln die Zusicherungen. Ein gut strukturiertes Programm kommt meist mit nur wenigen Zusicherungen aus. Gute Zusicherungen sind intuitiv klar. Es ist logisch, von einem Array in einer Klasse namens SortedIntArray zu verlangen, dass es sortiert ist. Namen von Klassen, Methoden, Variablen und Parametern kommt groe Bedeutung zu, weil gut gewhlte Namen die Intuition hinter den Zusicherungen klar machen. Zusicherungen, die man ohnehin aus den Namen ableitet, braucht man gar nicht hinzuschreiben. Beispielsweise sollte auch ohne Kommentare klar sein, dass eine Methode namens sort auf einer Datenstruktur die Elemente der Datenstruktur sortiert. Gut gewhlte Namen machen ein Programm einfach lesbar. Die Intuition hinter den Namen ist Teil der Spezikation eines Systems. In der objektorientierten Programmierung sind Namen besonders wichtig: Softwareobjekte simulieren Objekte aus der realen Welt, und Namen setzen die Softwareobjekte in Relation zu realen Objekten. Aufgrund unserer Erfahrungen in der realen Welt verstehen wir die Software. In Java
319
5 Qualittssicherung
beruhen auch die quivalenz von Typen sowie Untertypbeziehungen auf Namen. Prinzipiell unterscheidet man zwei Arten solcher Beziehungen: Typquivalenz aufgrund von Namensgleichheit bzw. explizite Untertypbeziehungen: Zwei Typen sind genau dann gleich, wenn die Typen dieselben Namen haben. Zwei Typen stehen genau dann in einer Untertypbeziehung, wenn eine explizite Beziehung zwischen den Namen dieser Typen (durch extends- und implements-Klauseln) hergestellt wurde. Stark typisierte objektorientierte Sprachen wie Java, C# und C++ verwenden hauptschlich diese Konzepte. Typquivalenz aufgrund von Strukturgleichheit bzw. implizite Untertypbeziehungen: Zwei Typen gelten als gleich, wenn ihre Instanzen dieselbe Struktur haben (unabhngig von Typnamen). Zwei Typen sind in Untertypbeziehung, wenn ein Typ zumindest alle Methoden untersttzt, die auch der andere untersttzt (auch ohne extendsoder implements-Klausel). Dynamisch typisierte objektorientierte Sprachen wie Ruby, Python und Smalltalk verwenden implizite Untertypbeziehungen. Typquivalenz aufgrund von Strukturgleichheit wird aber beispielsweise auch in Teilen von C und C++ verwendet. Programme in dynamischen objektorientierten Sprachen beruhen hug auf Duck-Typing. Diese Konzept ist nach einem Gedicht benannt, welches das Konzept gut beschreibt: When I see a bird that walks like a duck and swims like a duck and quacks like a duck, I call that bird a duck. James Whitcomb Riley Anders formuliert: Wenn mein Objekt zumindest alle Methoden hat, die ich von einer Instanz von Duck erwarte, dann kann ich das Objekt als Instanz von Duck verwenden. Es knnte reiner Zufall sein, dass alle Methoden von Duck vorhanden sind. Das macht nichts, solange sich das Objekt so wie erwartet verhlt. In stark typisierten Sprachen mchte man dagegen nicht von einer solchen Art von Zufall abhngig sein. Ein Objekt ist nur dann eine Instanz von Duck, wenn es durch new X(...) erzeugt wurde, wobei X gleich Duck oder ein explizit deklarierter Untertyp von Duck ist (beispielsweise durch class X extends Duck {...}). Der technische Unterschied zwischen expliziten Untertypbeziehungen und Duck-Typing ist folgender: Duck-Typing beruht nur auf der Untersttzung gleichartiger Methoden, whrend explizite Untertypbeziehungen
320
zustzlich auf weiteren Kriterien, die das Programm vorgibt, beruhen knnen. Im Wesentlichen werden diese zustzlichen Kriterien die in Zusicherungen ausgedrckten Eigenschaften sein. Duck-Typing ignoriert Zusicherungen, whrend explizite Untertypbeziehungen Zusicherungen bercksichtigen knnen. Auch explizite Untertypbeziehungen bercksichtigen Zusicherungen nur dann, wenn wir beim Programmieren dafr sorgen, dass die Zusicherungen zwischen Unter- und Obertypen zusammenpassen. In der Praxis sind viele Zusicherungen gar nicht explizit ausgedrckt, nichteinmal als Kommentare. Sie werden nur durch Namen impliziert oder beziehen sich auf die Vermeidung unerwarteter nderungen. Untertypbeziehungen mssen auch diese impliziten Zusicherungen erfllen. Hier hilft uns wieder die Relation zur realen Welt. Menschen mit Programmiererfahrung sind meist recht gut darin, bliche Untertypbeziehungen zu erraten, auch wenn sie die Zusicherungen gar nicht im Detail betrachten. Das wird in der objektorientierten Modellierung ausgentzt.
321
5 Qualittssicherung
In stark typisierten Sprachen berprft der Compiler die Kompatibilitt zwischen den Typen. Die Kompatibilitt zwischen Zusicherungen mssen wir selbst berprfen. Diese Verikation machen wir, indem wir fr jede Methode die Nachbedingungen und Invarianten aus den Anweisungen im Rumpf der Methode ableiten, wobei wir annehmen, dass die Vorbedingungen und Invarianten am Anfang erfllt sind. Auerdem mssen wir uns vergewissern, dass bei jedem Methodenaufruf die Vorbedingungen der aufgerufenen Methode sowie Invarianten des Empfngers der Nachricht sowie aller Argumente erfllt sind. Diese berprfungen knnen recht aufwendig sein. Zur Vereinfachung der Herleitung aller notwendiger Bedingungen knnen wir sozusagen als Zwischenschritte im Beweis weitere Zusicherungen in den Rumpf der Methode schreiben. Listing 5.3 gibt ein Beispiel dafr. Die Klasse SortedIntArray aus Listing 5.2 wird um eine binre Suche hnlich der in Listing 4.22 erweitert. Um die Zusicherungen auf eine unzweideutige, formale Basis zu stellen, verwenden wir assert-Anweisungen. Hauptschlich werden die berprfungen von eigens dafr implementierten privaten Methoden vorgenommen. Die Methode elemsSorted berprft in einer einfachen Schleife, ob das Array elems sortiert ist. Zu Beginn und am Ende (also vor jeder return-Anweisung) muss die Invariante erfllt sein. Da elems in einer Ausfhrung von member nirgends verndert wird, knnen wir davon ausgehen, dass das Array am Ende noch immer sortiert ist, wenn es zu Beginn sortiert war. Zu Beginn ist es sortiert, weil es am Ende jeder Methode von SortedIntArray sortiert ist und auer den Methoden dieser Klasse niemand auf das Array zugreifen kann. Die Nachbedingung auf member knnte so klingen: Das Ergebnis ist true wenn x in elems vorkommt und false wenn x in elems nicht vorkommt. In Listing 5.3 ist die Nachbedingung jedoch anders formuliert: x == elems[k] (fr irgendein k) impliziert true als Ergebnis, und clean(x,i,j) && i>j impliziert false. Dabei stellt der Aufruf von clean(x,i,j) sicher, dass x in elems auerhalb des Indexbereiches i bis j nicht vorkommt. Im Spezialfall von i>j kann x in elems berhaupt nicht vorkommen. Diese etwas umstndliche Ausdrucksweise hilft bei der berprfung, ob die Bedingungen zutreen. Zur weiteren Untersttzung dient die Zusicherung clean(x,i,j) zu Beginn jeden Schleifendurchlaufs. Fr den ersten Schleifendurchlauf ist diese Bedingung natrlich erfllt, da clean(x,0,[Link]-1) immer true zurckgibt. In jedem Schleifendurchlauf wird irgendein Index k zwischen i und j ge-
322
Listing 5.3: Binre Suche Zusicherungen zur berprfung der Korrektheit public class SortedIntArray { private int[] elems; // elems stets sortiert ... public boolean member(int x) { // x enthalten? assert elemsSorted(); // Invariante hlt zu Beginn int i = 0; // untere Grenze int j = [Link] - 1; // obere Grenze while (i <= j) { // bis alles durchsucht: assert clean(x, i, j); int k = i + ((j - i) / 2); // probiere die Mitte if (x < elems[k]) { j = k - 1; // links weitersuchen } else if (x == elems[k]) { assert elemsSorted(); // Invar. hlt am Ende assert x == elems[k]; // x in elems => true return true; } else { i = k + 1; // rechts weitersuchen } } assert elemsSorted(); // Invariante hlt am Ende assert clean(x,i,j) && i > j; // x nicht in elems => false return false; } private boolean elemsSorted() { // ist elems sortiert? for (a = 1; a < [Link]; a++) if (elems[a - 1] > elems[a]) return false; return true; } private boolean clean(int x, int i, int j) { while (--i >= 0) // x nicht in elems bis i-1 if (elems[i] == x) return false; while (++j < [Link]) // x nicht in elems ab j+1 if (elems[j] == x) return false; return true; } }
whlt, und falls x ungleich elems[k] ist, wird k zur neuen unteren oder oberen Grenze, je nach Gre des Wertes in elems[k]. Aufgrund der Sortiertheit von elems und der Auswahl der Grenzen gilt auch fr die
323
5 Qualittssicherung
neuen Grenzen clean(x,i,j), auch dann, wenn die Schleife wegen i>j abbricht. Damit haben wir schon die Nachbedingung gezeigt. Bei dieser statischen Analyse der Methode haben wir viele Details ignoriert. Nachbedingung und Invariante hngen beispielsweise nicht davon ab, wie k gewhlt wird. Solche Aspekte sind zwar fr die Ezienz der Methode wichtig, aber nicht um die Korrektheit hinsichtlich der Zusicherungen zu berprfen. Generell wird das Programmverstndnis erleichtert, wenn wir einen Aspekt nach dem anderen betrachten, nicht alle gleichzeitig. Eigentlich brauchen wir keine einzige der assert-Anweisungen in Listing 5.3 (genausowenig wie die Methoden elemsSorted und clean) da wir aufgrund unserer Analysen wissen, dass diese Zusicherungen nicht verletzt sein knnen. Das ist typisch fr alle empfohlenen Anwendungen von assert-Anweisungen: Man soll sie nur dort einsetzen, wo man wei, dass die Bedingungen immer wahr sind. Wenn Zweifel daran bestehen, dass eine Bedingung erfllt ist, kann man die Bedingung beispielsweise in einer ifAnweisung einsetzen, aber nicht in einer assert-Anweisung. Nicht erfllte assert-Anweisungen fhren in der Regel ja zum Programmabbruch. In Abschnitt 5.4 werden wir sehen, wie verletzte assert-Anweisungen beim Finden von Fehlern helfen knnen. Sie sind also nicht sinnlos. Die Methoden elemsSorted und clean dienen in erster Linie zur genauen Spezikation der Zusicherungen, nicht dazu, ausgefhrt zu werden. Die berprfung von Zusicherungen zur Laufzeit kann sehr aufwendig sein. Beispielsweise hat die normale binre Suche nur einen Aufwand von O(log(n)), aber O(n log(n)) wenn Zusicherungen wie in Listing 5.3 berprft werden. Ein solcher Aufwand ist meist inakzeptabel. Daher ist die berprfung von Zusicherungen fast immer ausgeschaltet. Wenn man Zusicherungen berprfen lassen mchte, muss man beim Aufruf des Interpreters das Flag -ea setzen. Das heit, wird das Programm durch java -ea Program gestartet, werden assert-Anweisungen berprft, wird es durch java -da Program oder java Program gestartet, ist die berprfung ausgeschaltet und das Programm luft ezienter. Weil assert-Anweisungen nicht immer berprft werden, drfen sie keine Ausdrcke enthalten, die fr die korrekte Programmausfhrung notwendig sind, sondern nur eigentlich berssige berprfungen. 5.2.2 Schleifeninvarianten Bisher haben wir den Begri Invariante nur fr eine bestimmte Form von Zusicherungen auf Objektschnittstellen verwendet. Diese Invarianten
324
beschreiben Eigenschaften von Objektzustnden, die zu Beginn und am Ende jeder Methodenausfhrung erfllt sein mssen. Daneben gibt es auch Schleifeninvarianten. Das sind Eigenschaften, die zu Beginn und am Ende jeden Schleifendurchlaufs erfllt sein mssen. In Listing 5.3 ist beispielsweise clean(x,i,j) eine Invariante auf der Schleife in member. Schleifen sind oft besonders schwierig aus statischer Sicht zu verstehen, da der Programmfortschritt nur durch wiederholte dynamische Vernderungen des Zustandes erfolgen kann; Vernderungen sind nicht statisch. Meistens wissen wir nicht, wie oft eine Schleife durchlaufen wird. Daher ist es auch nicht mglich, die Schleife zu verstehen, indem wir sie gedanklich ausrollen (englisch Loop-Unrolling ), also die Schleife durch soviele hintereinander auszufhrende Kopien des Schleifenrumpfes ersetzen, sooft die Schleife durchlaufen wird. Schleifeninvarianten bleiben von dynamischen nderungen verschont und ermglichen ein statisches Verstndnis. Generell funktionieren Schleifeninvarianten nach folgendem Schema: Eine Bedingung I ist eine Schleifeninvariante, wenn I zu Beginn und am Ende jedes Durchlaufs durch den Schleifenrumpf R erfllt ist. Dadurch ist I auch vor dem ersten und nach dem letzten Schleifendurchlauf erfllt. Fr eine while-Schleife mit der Abbruchbedingung A gilt also: assert I ; while (!A) { assert I ; R; assert I ; } assert I && A; Nach Beendigung der Schleife ist nicht nur die Schleifeninvariante, sondern auch die Abbruchbedingung erfllt. In Listing 5.3 wird clean(x,i,j) genau nach diesem Schema verwendet, auch wenn aus Grnden der Vereinfachung einige assert-Anweisungen weggelassen wurden. Wie das Beispiel zeigt, mssen Schleifeninvarianten nicht unbedingt konstant sein. Die Variablen i und j knnen in jedem Schleifendurchlauf unterschiedliche Werte haben, aber die Bedingung gilt trotzdem. Hinter Schleifeninvarianten steckt die vollstndige Induktion als Beweistechnik: Den Induktionsanfang bildet die Erflltheit der Invarianten vor Schleifenbeginn. Der Induktionsschritt besteht darin, dass die Invariante auch fr den (n + 1)-ten Schleifendurchlauf gelten muss, wenn sie fr den n-ten Durchlauf gilt. Das ist gewhrleistet, weil die Invariante am Anfang und Ende jeden Schleifendurchlaufs gelten muss. In der Praxis ist es oft schwierig, in einem bestehenden Programm passende Schleifeninvarianten zu nden. Wenn man sie einmal gefunden hat, ist es in der Regel leicht, die Korrektheit des Programms zu verizieren.
325
5 Qualittssicherung
Andere Sprachkonstrukte sind einfacher statisch zu verstehen. Bei der Suche nach Schleifeninvarianten geht man oft von den Nachbedingungen aus, die am Ende der Methodenausfhrungen gelten mssen. Sie liefern gute Hinweise darauf, welche Schleifeninvarianten bentigt werden um das Programm verizieren zu knnen. Dann sucht man nach einer Mglichkeit, diese Nachbedingungen zu erfllen. Invarianten auf Objektschnittstellen eignen sich meist auch als Schleifeninvarianten, sind aber leider oft trivial (beispielsweise elems wird nicht verndert) und helfen daher kaum bei den schwierigen Teilen des Beweises der Nachbedingungen. Whrend der Konstruktion eines Programms wissen wir, warum wir eine Schleife einsetzen und was diese Schleife bezweckt. Genau diesen Zweck knnen und sollen wir als Kommentar in das Programm schreiben. Solche Kommentare machen deutlich, wie wir beim Programmieren denken. Mit weniger Programmiererfahrung verdeutlichen die Kommentare eher den dynamischen Ablauf, der in der Syntax der Schleifen ohnehin gut erkennbar ist. Mit zunehmender Programmiererfahrung spiegeln die Kommentare immer strker eine statische Denkweise wider. Im Idealfall lsst sich jeder solche Kommentar als Schleifeninvariante lesen. Dann ist es nur mehr ein kleiner Schritt von informellen Kommentaren zu formalen assert-Anweisungen. Es gilt also: Die Suche nach guten Schleifeninvarianten knnen wir bedeutend vereinfachen, wenn wir uns eine statische Denkweise bereits beim Schreiben der Schleife angewhnen und die wichtigsten Gedanken als Kommentare niederschreiben. Schleifen knnen durch Rekursion ersetzt werden. Rekursive Aufrufe werden durch geeignete Zusicherungen verstndlich. Statt Schleifeninvarianten verwenden wir jedoch Vor- und Nachbedingungen bzw. Invarianten auf Objektschnittstellen. Ein weiterer wichtiger Unterschied besteht darin, dass rekursive Aufrufe berall innerhalb eines Methodenrumpfs erfolgen knnen, nicht nur wie bei Schleifen am Ende des Rumpfs. Entsprechend mssen Vorbedingungen und Invarianten unmittelbar vor jedem (rekursiven) Aufruf erfllt sein. Whrend dieselbe Schleifeninvariante am Anfang und Ende einer Schleife erfllt sein muss, knnen sich Vor- und Nachbedingungen einer Methode voneinander unterscheiden. Vor und nach rekursiven Aufrufen knnen ja noch weitere Anweisungen ausgefhrt werden, die sich auf die Bedingungen auswirken. Im Vergleich zu Schleifen geben uns rekursive Methoden damit etwas mehr Freiheit beim Programmieren und bei der Dokumentation von Programmen. Bei der Konstruktion rekursiver Methoden gehen wir am besten genauso vor wie bei der von Schleifen: Wir halten den Zweck der Methoden und
326
von rekursiven Aufrufen in Kommentaren fest und achten darauf, dass diese Kommentare eine mglichst statische Sichtweise widerspiegeln. Obwohl in diesem Abschnitt von Verikation und Beweisen gesprochen wird, drfen wir nicht vergessen, dass das wichtigste Ziel hinter Zusicherungen die Verbesserung der Verstndlichkeit des Programms ist. Beweisverfahren brechen komplizierte Aussagen auf eine Menge so einfacher Aussagen herunter, dass niemand an deren Korrektheit zweifeln kann. Genauso erklren Zusicherungen komplizierte Programmteile auf so einfache Weise, dass sie verstndlich sind. Idealerweise geht beides Hand in Hand: Ein guter Beweis erklrt, wie und warum ein Programmteil funktioniert und macht ihn dadurch verstndlich. 5.2.3 Termination Neben der Einhaltung von Zusicherungen mssen wir auch auf die Termination von Schleifen und rekursiven Methoden achten. Zusicherungen versprechen nur, dass in jedem Schleifendurchlauf bzw. bei jedem rekursiven Aufruf bestimmte Eigenschaften erfllt sind. Sie knnen keine Obergrenze fr die Anzahl der Schleifendurchlufe oder Methodenaufrufe geben. Solche Obergrenzen mssen wir durch andere Techniken sicherstellen. Die Einhaltung aller Zusicherungen garantiert die partielle Korrektheit des Programms, bei der alle Ergebnisse den Spezikationen entsprechen, falls das Programm Ergebnisse liefert. Termination ist dafr nicht erforderlich. Vollstndige Korrektheit erweitert die partielle Korrektheit um Termination und garantiert damit, dass das Programm Ergebnisse liefert, die den Spezikationen entsprechen. Eine Voraussetzung fr die Termination einer Schleife ist, dass jeder einzelne Schleifendurchlauf uns ein ausreichend groes Stck nher an das Ergebnis heranbringt. Um die Termination einer Schleife zu beweisen, mssen wir den Fortschritt pro Schleifendurchlauf abhngig von Eingabewerten in Zahlen fassen. Damit knnen wir eine von der Eingabe abhngige obere Schranke fr die Anzahl der Schleifendurchlufe berechnen. Diese Berechnung braucht nicht genau zu sein, sondern eine ganz grobe Abschtzung reicht. Wir mssen ja nur irgendeine ober Schranke nden. Allerdings drfen wir bei der Abschtzung nur in Richtung einer hheren Schranke und eines kleineren Fortschritts pro Schleifendurchlauf ungenau sein. Wir drfen niemals annehmen, dass in einem Schleifendurchlauf mehr gemacht wird als tatschlich passiert, und wir drfen keinesfalls eine kleinere Schranke als die tatschliche Schranke annehmen.
327
5 Qualittssicherung
Betrachten wir die Methode member aus Listing 5.3 als Beispiel. Meist liefert die Abbruchbedingung einen guten Hinweis darauf, wo wir nach einer geeigneten Schranke suchen sollen. Wir knnen den Abstand zwischen i und j (wobei i kleiner oder gleich j ist) als obere Schranke fr die Anzahl der Schleifendurchlufe heranziehen, wenn sichergestellt ist, dass sich in jedem einzelnen Schleifendurchlauf i um mindestens eins erhht oder j um mindestens eins verringert. In jedem Schleifendurchlauf wird entweder i auf k + 1 oder j auf k - 1 gesetzt, wobei k gleich i + ((j - i) / 2) ist. Damit der Abstand sich verringert, muss k zwischen i und j liegen, einschlielich i und j selbst. Fr grere Abstnde zwischen i und j ist diese Bedingung jedenfalls erfllt, da k etwa in der Mitte zwischen i und j liegt. Ist i gleich j oder nur um eins kleiner als j, dann ist k gleich i und die Bedingung somit erfllt. Am Beginn der Schleife ist der Abstand zwischen i und j gleich der Gre des Arrays. Aufgrund dieser berlegungen wissen wir, dass die Schleife nach sptestens [Link] Durchlufen terminiert. Die Berechnung einer Schranke fr die Anzahl der Schleifendurchlufe hat viel mit der Aufwandsabschtzung eines Algorithmus im schlechtesten Fall gemeinsam. Im Detail zeigen sich jedoch Unterschiede. Die Aufwandsabschtzung fr die binre Suche in Abschnitt 4.4.3 hat gezeigt, dass wir nur logarithmischen Aufwand haben. Wir erwarten eine deutlich niedrigere Schranke fr die Anzahl der Schleifendurchlufe, wenn wir bercksichtigen, dass der Abstand zwischen i und j in jedem Schleifendurchlauf zumindest halbiert wird. Fr grere Abstnde zwischen i und j ist diese Bedingung immer erfllt. Ist aber i gleich j und der Abstand daher gleich Null, dann ist auch der halbe Abstand gleich Null, und aufgrund dieser Abschtzung erzielen wir in einem Schleifendurchlauf keinen Fortschritt, da i und j mglicherweise unverndert bleiben. Alleine aufgrund der Halbierung des Abstands ist keine Termination garantiert. Wir mssen die zustzliche Information benutzen, dass (wie oben gezeigt) jeder Schleifendurchlauf i um mindestens eins erhht oder j um mindestens eins verringert. Erst dadurch ergibt sich eine Schranke logarithmisch zu [Link]. Wenn wir nur die Termination der Schleife zeigen wollen, hat das Wissen ber die Halbierung der Abstnde keine Vorteile. Wir mssen noch die Frage klren, wie gro der Fortschritt pro Schleifendurchlauf mindestens sein muss, damit Termination garantiert ist. Wenn jeder Schleifendurchlauf einen etwa gleich groen Fortschritt grer Null erzielt und die zu bearbeitende Datenmenge endlich ist, dann terminiert die Schleife sicher irgendwann. Wenn der Fortschritt dagegen von Durch-
328
lauf zu Durchlauf immer kleiner wird, ist Vorsicht geboten, wie wir bei der Halbierung der Abstnde zwischen i und j gesehen haben. Als Vergleich knnen wir die Entwicklung mathematischer Reihen heranziehen: Bei Halbierung der Abstnde wird im ersten Schritt die Hlfte der Suche erledigt, also 1/2, im zweiten Schritt 1/4 und so weiter. Insgesamt erledii gen wir in n Schleifendurchlufen einen Anteil von n i=1 1/2 der Suche. i Aus der Mathematik wissen wir, dass i=1 1/2 = 1 gilt und daher leider unendlich viele Schritte ntig wren. Die binre Suche terminiert nur, weil in jedem Schleifendurchlauf tatschlich etwas mehr als die Hlfte erledigt wird, nmlich die Hlfte plus einem Element (das, welches im Vergleich i betrachtet wird). Die entsprechende Reihe i=1 1/2 + 1/k (wobei k die Anzahl der Elemente ist) geht gegen da auch i=1 1/k gegen geht. Die Suche terminiert, sobald eine Partialsumme (bestehend aus den ersten Summanden) eins bersteigt. Zum Sicherstellen der Termination kommt es nicht auf den Beitrag des grten Gliedes der Reihe an, sondern auf die Beitrge der kleinsten Glieder. Praktisch gesehen mssen wir berlegungen zur Termination stndig whrend des Programmierens anstellen und dabei auch den Fortschritt pro Schleifendurchlauf beachten. Wenn wir das nicht machen, kann es beispielsweise sehr leicht passieren, dass wir in der binren Suche in Listing 5.3 statt j=k-1 und i=k+1 einfach nur j=k und i=k schreiben. Auf den ersten Blick wirkt die einfachere Variante genauso gut, und viele Testflle werden keine nderung im Programmverhalten zeigen. Aber die Termination ist nicht mehr garantiert (wenn die gesuchte Zahl nicht gefunden wird), da die Abstnde tatschlich nur halbiert werden. Irgendwann wird das Programm in einer Endlosschleifen hngen bleiben. Wenn wir statt Schleifen Rekursion verwenden, lsst sich die Termination auf genau die gleiche Art zeigen wie bei Schleifen. Wir mssen nur die Beitrge jeden einzelnen Methodenaufrufs an der gesamten zu erledigenden Arbeit aufsummieren. Das Programm wird terminieren, sobald eine Partialsumme der Reihe den Wert eins bersteigt. Aus Abschnitt 1.5.3 wissen wir, dass das Halteproblem im Allgemeinen unentscheidbar ist. Daher gibt es Programme und Algorithmen, bei denen wir auch nach genauer Analyse nicht wissen, ob sie terminieren. Das soll jedoch keine Ausrede sein. In der Praxis setzen wir fast nur Algorithmen ein, von denen wir wissen, dass sie terminieren. Diese Termination kann man auch zeigen. Wenn die Termination nicht beweisbar ist, wird man fast immer einen anderen Algorithmus einsetzen und den mglicherweise nicht terminierenden Algorithmus als falsch betrachten. Nur ganz wenige
329
5 Qualittssicherung
hochgradig spezialisierte Leute beschftigen sich mit Algorithmen in engen Nieschenbereichen, die in machen Fllen nicht terminieren. In diesen Bereichen sind viel genauere Analysen notwendig um sicherzustellen, dass die Nichttermination zu keinen Problemen fhrt. Das Halteproblem wirkt sich praktisch so aus, dass Programmiersprachen keine Termination garantieren. Man kann in jeder vollstndigen Programmiersprache Endlosschleifen schreiben. Zum Beweis der Termination mssen wir fr jede Schleife andere, an die Situation angepasste Argumente anfhren. Wenn wir eine Sprache so beschrnken, dass jede Schleife und Rekursion garantiert terminiert, dann haben wir bereits in der Sprache eine Auswahl der erlaubten Argumente getroen, und viele Schleifen, die aufgrund anderer Argumente terminieren wrden, wren nicht erlaubt. Termination ist ein sehr grobes Kriterium. Meist reicht es nicht, wenn eine Berechnung nur terminiert, sie soll im Normalfall auch nach mglichst kurzer Zeit terminieren. Dazu sind Aufwandsabschtzungen oder Laufzeitmessungen ntig, bei denen wir aber oft nur den durchschnittlichen Aufwand betrachten. berlegungen zur Termination gelten dagegen immer fr den schlechtesten Fall, der praktisch fast nie eintritt und daher auch durch Testen kaum zu nden ist. Manche Programme sollen nicht terminieren. Beispielsweise erwarten wir von der Software einer Telefonanlage, dass sie stndig luft und niemals aufhrt, ihre Aufgaben zu erfllen. Trotzdem und gerade deswegen ist es in solchen Systemen besonders wichtig, dass die einzelnen Teilaufgaben terminieren und jeder Schleifendurchlauf den ntigen Fortschritt bringt. Es gelten also im Wesentlichen alle in diesem Abschnitt angestellten berlegungen. Allerdings ist die Datenmenge, auf die das Programm angewendet wird, unbegrenzt. Jeder auf einmal verarbeitete Ausschnitt aus der Datenmenge muss in endlicher (und in der Regel kurzer) Zeit ein Teilergebnis liefern, damit das Programm sinnvoll ist. 5.2.4 Beweise und deren Grenzen Formale Beweise der Programmkorrektheit und ein statisches Programmverstndnis gehen Hand in Hand. Wir verstehen ein Programm, indem wir uns berlegen, wie wir dessen Korrektheit beweisen knnen. Umgekehrt setzt ein Korrektheitsbeweis auch ein gutes Programmverstndnis voraus. Heute haben wir ein umfangreiches Sortiment an Werkzeugen, die uns dabei helfen, Beweise zu nden. Zu den einfachsten Werkzeugen zhlen die Typen. Sie schrnken die Konstruktion von Programmen derart ein,
330
dass jedes Programm, das vom Compiler akzeptiert wird, bestimmte Eigenschaften erfllt. Beispielsweise knnen nur solche Nachrichten gesendet werden, fr welche die Empfnger passende Methoden implementiert haben. Wir verlassen uns darauf, dass aufgerufene Methoden tatschlich existieren. Insofern beeinussen Typen auch unseren Programmierstil und die Art und Weise, wie wir beim Programmieren denken. Das ist ein wichtiger Grund dafr, warum Typen nur mit eher einfachen und fast immer sinnvollen Eigenschaften umgehen knnen. Man kann nicht wegen einer Eigenschaft, die nicht in fast jedem Programm gebraucht wird, eine bestimmt Denkweise und Art der Programmkonstruktion vorgeben. Die Freiheit bei der Programmierung wrde dadurch zu stark eingeschrnkt. In letzter Zeit wurden groe Fortschritte bei der Steigerung der Ezienz von Verfahren zur automatischen Verikation von Programmen erzielt. Vor allem Model-Checking wird seit kurzem auch praktisch eingesetzt: Man bergibt einem Werkzeug nur eine Systembeschreibung (beispielsweise in Form eines Programms) und eine logische Eigenschaft, und das Werkzeug berprft selbstndig, ob die Eigenschaft mit der Systembeschreibung in Einklang steht. Das ist der Fall wenn in der Terminologie der mathematischen Logik die Eigenschaft ein Modell der Systembeschreibung ist. Im Idealfall ndet das Werkzeug keinen Gegenbeweis und besttigt damit die Eigenschaft. Sehr hilfreich ist die Tatsache, dass das Werkzeug ein Gegenbeispiel liefert, wenn die Eigenschaft nicht erfllt ist. Es zeigt recht genau auf, an welchen Stellen man die Systembeschreibung bzw. das Programm ndern muss, damit die gewnschte Eigenschaft erfllt wird. Trotz groer Fortschritte in den letzten Jahren lassen sich viele Eigenschaften leider noch immer nur fr ganz kleine Programme beweisen. Oft hat das Werkzeug nicht genug Speicher zur Verfgung oder rechnet tagelang ohne erkennbaren Fortschritt. Fr bestimmte Eigenschaften und Programme geeigneter Gre ist Model-Checking dagegen sehr erfolgreich. Ein bekannter Model-Checker fr Java-Programme ist Java-Pathnder (JPF).2 Er wird als Schweizer Messer fr die Java-Verikation beschrieben. Unter anderem kann man damit Zusicherungen in Form von ber Annotationen formal denierten Vorbedingungen, Nachbedingungen und Invarianten berprfen. Dabei wird bereits vor Programmausfhrung berprft, ob alle Zusicherungen zur Laufzeit halten werden. Eine erfolgreiche berprfung der Zusicherungen kann die Zuverlssigkeit eines Programms erheblich steigern. Sogar wenn Fehler gefunden werden, steigt die Qualitt
2
Siehe [Link]
331
5 Qualittssicherung
nach Beseitigung der Fehler. Es ist klar, dass solche berprfungen sehr aufwendig sind. JPF kann fr einen Beweis sehr lange brauchen und wird in vielen Fllen in vertretbarer Zeit zu keinem Ergebnis kommen. Generell kann man nur konkrete Eigenschaften beweisen, die sich in eine relativ einfache formale Form bringen lassen. Eine solche Eigenschaft wre beispielsweise, dass das Ergebnis einer bestimmten Methode niemals den Wert null haben darf. Oft ist unsere Vorstellungen davon, welche Eigenschaften wir erwarten, aber nur sehr vage. Zum Beispiel wollen wir vermeiden, dass jemand unter Zuhilfenahme unseres Programms irgendeinen Schaden anrichtet. Es ist nicht klar, auf welche Weise konkreter Schaden angerichtet werden knnte. Natrlich soll kein Fremder Zugang zu nicht fr die entlichkeit bestimmten Daten bekommt oder diese sogar ndern knnen, aber es bleibt oen, wer als Fremder gilt und welche Daten genau entlich sichtbar sein knnen. Ein groes Problem sind Fehler in einem Programm, ber die jemand mit guter Kenntnis des Programms etwas machen kann, wofr das Programm nicht vorgesehen ist und das man vermeiden mchte. Es ist unmglich, alle solchen potentiellen Fehler aufzuzhlen. Vieles von dem, was sich spter als Fehler herausstellt, war ursprnglich als sinnvolles Feature gedacht. Wenn uns bewusst wre, was alles als Fehler anzusehen ist, htten wir die meisten davon (unabhngig von Beweisen) gar nicht gemacht. Im praktischen Einsatz zeigen sich deren Auswirkungen oft erst nach Jahren, falls berhaupt. Auch die ausgefeilteste Beweistechnik kann nichts ausrichten, wenn nicht klar ist, welche Eigenschaften erwnscht und welche unerwnscht sind. Erwnschte und unerwnschte Eigenschaften knnen nahe beieinander liegen. Das zeigt sich deutlich bei Denial-of-Service-Attacken (DoS). Ziel der meisten Systeme ist es, alle Anfragen rasch zu bearbeiten. Aber es gibt auch anonyme Anfragen aus dem Web, die Bses im Schilde fhren: Jemand kann in kurzer Zeit absichtlich so viele Anfragen stellen, dass das System berlastet ist und die Dienste nicht mehr ordnungsgem erfllen kann. Auf diese Weise wird (mit entsprechend hohem Aufwand) fast jedes System vorbergehend unbrauchbar. Es gibt aber Gegenstrategien. Man unterscheidet ernst gemeinte Anfragen von mglicherweise bsen und unterbindet letztere. Vielleicht muss man sich einloggen oder vor Inanspruchnahme eine fr Menschen leichter als fr Maschinen lsbare Aufgabe lsen. Es wird damit schwieriger, das System zu benutzen. Natrlich wnschen wir uns einen direkten, oenen Zugang und rgern uns ber Einschrnkungen. Aber aus dieser erwnschten Eigenschaft wird rasch eine unerwnschte und umgekehrt, wenn DoS-Attacken auftreten.
332
5.3 Testen
Automatisierte Verfahren zur Programmverikation erhhen zwar die Zuverlssigkeit, aber sie tragen kaum zum Programmverstehen bei und versagen bei schwammigen Zielvorstellungen. Ein Code-Review kann auch damit umgehen. Dabei begutachtet ein erfahrener Reviewer den Quellcode eines Programms bzw. Programmteils, stellt Fragen und macht Verbesserungsvorschlge dazu. ber Code-Reviews werden Fehler aus ganz unterschiedlichen Bereichen gefunden, beispielsweise Verletzungen von Konventionen und Standards, schlechte oder falsche Kommentare, unklare, widersprchliche oder verletzte Spezikationen, Nichterflltsein von Anforderungen und unzureichende Wartbarkeit. Gerade die Abdeckung eines so weiten Bereichs an potentiellen Fehlern, von denen viele einem formalen Beweis nicht zugnglich sind, trgt wesentlich zur Qualittsverbesserung bei. Im Gegensatz zum Testen stellen Code-Reviews ein statisches Verfahren zur Qualittsverbesserung dar. So wie beim Testen, aber anders als bei formalen Beweisen, wird keine Fehlerfreiheit in einem Bereich garantiert.
5.3 Testen
Testen ist zur Qualittskontrolle unverzichtbar. So mancher Fehler tritt nur bei ausgiebigem Testen zu Tage. Wir wollen betrachten, wie sich das Testen auf die Qualitt eines Programms auswirkt und welche Vorgehensweisen zu unterscheiden sind. Schlielich gehen wir auf Laufzeitmessungen ein, die als spezielle Form des Testens betrachtet werden knnen. 5.3.1 Auswirkungen auf Softwarequalitt Zweifellos erhht Testen die Qualitt eines Programms. Eine naive Annahme geht davon aus, dass ein gefundener Fehler gleich beseitigt wird und das Programm danach eine hhere Qualitt hat. Ganz so einfach sind die Auswirkungen des Testens und der Fehlerkorrektur auf die Programmqualitt jedoch nicht. Folgende Faktoren spielen eine Rolle: Testflle knnen niemals alle Mglichkeiten abdecken. Auch sehr intensives Testen kann keine Fehlerfreiheit garantieren. Die Anzahl der gefundenen Fehler lsst Rckschlsse auf die Anzahl der im Programm vorhandenen Fehler zu. Werden bei gleicher Testmethode in einem Programm A mehr Fehler gefunden als in einem Programm B , dann enthlt A wahrscheinlich auch mehr nicht gefundene Fehler als B , und B ist somit von hherer Qualitt.
333
5 Qualittssicherung
Das Beseitigen eines gefundenen Fehlers fhrt leicht zu weiteren Fehlern. Sobald man einen Fehler gefunden hat, ist die Verlockung gro, ihn sofort an der Stelle, an der er aufgetreten ist, zu beseitigen. Sehr oft liegt die tatschliche Ursache des Fehlers jedoch irgendwo anders. Durch die Ausbesserung hat man daher nur ein Symptom beseitigt, aber nicht die Fehlerursache. Die Ausbesserung ist nicht nur nicht zielfhrend, sondern falsch: Wenn die eigentliche Fehlerursache spter entdeckt und beseitigt wird, stellt sich die Ausbesserung pltzlich als neuer Fehler dar. Deshalb soll man vor Fehlerkorrekturen sehr sorgfltig die Ursachen erforschen und niemals einen Fehler ausbessern, dessen Ursache man nicht ganz genau kennt. Fehler knnen in jeder Phase der Softwareentwicklung passieren, auch in der Analyse- und Entwurfsphase. Mglicherweise sind auch Testflle falsch. Bei der Suche nach der Fehlerursache darf man sich nicht nur auf die Implementierung konzentrieren. Manche Fehler in einem Programm werden nur mit uerst kleiner Wahrscheinlichkeit sichtbar, wenn zufllig mehrere ganz selten erfllte Voraussetzungen dafr zusammenkommen. Diese Fehler sind beim Testen kaum erkennbar. Mit etwas Glck treten sie auch im praktischen Einsatz des Programms nicht auf. Es ist daher kein Problem, wenn sie von niemandem erkannt werden. Fehler, die nur ganz selten zu Tage treten, knnen dazu benutzt werden, in ein System einzubrechen oder das System lahmzulegen. Angreifer stellen die Voraussetzungen fr das Sichtbarwerden eines Fehlers, die normalerweise praktisch nie erfllt sind, knstlich her und lsen damit den Fehler absichtlich aus. Erkannte Fehler mit schwerwiegenden Auswirkungen sind daher rasch zu beseitigen. Das gilt auch fr Fehler, die bei der blichen Verwendung des Programms nicht sichtbar werden knnen. Zur Absicherung gegen Missbrauch muss man auch das Programmverhalten bei unerwnschten und unblichen Verwendungen testen. Es ist viel Fantasie und Fingerspitzengefhl ntig um mgliche unerwnschte Verwendungsweisen zu erkennen. Es kommt vor, dass das Eindringen in ein System absichtlich ermglicht wird, beispielsweise um das Testen und die Suche nach Fehlerursachen zu vereinfachen. Derartiges ist gefhrlich und zu vermeiden.
334
5.3 Testen
Zusammengefasst kann man aus diesen Punkten den Schluss ziehen, dass Testen in erster Linie der Qualittskontrolle dient. Durch intensives Testen stellen wir sicher, dass unser Programm die erwartete geringe Fehlerwahrscheinlichkeit aufweist nicht zu verwechseln mit Fehlerfreiheit. In allen Phasen der Softwareentwicklung mssen wir uns darum bemhen, diese Qualitt zu erreichen. Testen dient durch Aufdecken und anschlieende Beseitigung von Fehlern, wenn berhaupt, nur sehr beschrnkt zur direkten Qualittsverbesserung. Eine indirekte Qualittsverbesserung erhalten wir jedoch dadurch, dass uns beim Testen aufgedeckte Fehler gute Hinweise darauf geben, in welchen Bereichen wir die Qualitt ber andere Mittel verbessern mssen. Bereits in Abschnitt 1.6.2 haben wir gesehen, wie ein blicher Ablauf der Programmkonstruktion aussieht: Auf das Testen folgt das Debuggen, bei dem Fehlerursachen ergrndet werden. Erst nach dem Finden der tatschlichen Fehlerursachen knnen wir uns einen Plan zurechtlegen, wie wir diese beseitigen. Hug sind dafr grere Umstrukturierungen ntig. Die Qualittsverbesserung tritt dadurch ein, dass wir die beim Testen und Debuggen gewonnene Erfahrung in die berarbeitete Planung und Umstrukturierung einbeziehen. Nur die Ursachen ganz trivialer Fehler sind sofort erkenn- und beseitigbar. Zu einer merklichen Qualittsverbesserung fhren in der Regel nur Techniken, die uns dabei helfen, das Programm auf statische Weise zu verstehen. Dazu zhlen Code-Reviews (siehe Abschnitt 5.2.4), aber das Testen eher nicht. Ergebnisse von Testlufen helfen eventuell dabei, unsere Bemhungen zum statischen Verstehen auf die richtigen Programmteile zu lenken. Vor allem helfen aufgedeckte Fehler aber dabei, unsere Aufmerksamkeit bei Code-Reviews auf bisher zu wenig beachtete Aspekte zu lenken. Nachdem wir beispielsweise eine Endlosschleife entdeckt haben, werden wir auch an ganz anderen Programmstellen verstrkt auf Schleifeninvarianten und Beweise der Termination achten. Testergebnisse nen uns die Augen dafr, worauf wir achten mssen. Wird das Testen auf diese Weise zum Gewinnen von Erfahrung eingesetzt, kann das Erkennen eines Fehlers zahlreiche weitere Fehler vermeiden. In frherer Zeit hat man Programmieren gelernt, indem man ein Programm vollstndig auf Papier entwickelt und sich ber Beweise vergewissert hat, dass es funktioniert. Erst wenn man sich sicher war, durfte man das Programm auch am Computer ausprobieren. Mit dieser Vorgehensweise wurde das statische Programmverstndnis gefrdert und die Verschwendung damals noch teurer Rechenzeit verhindert. Diese Vorgehensweise ist mit der beinahe unbeschrnkten Verfgbarkeit von Computern
335
5 Qualittssicherung
verschwunden. Allerdings hat sich am ursprnglichen Ziel nichts gendert: Auch heute sollte man darauf achten, nur fertige und gut durchdachte Programmteile zu bersetzen und auszuprobieren. Das Durchdenken des Programmcodes wie beim Code-Review bringt uns qualitativ hochwertige Programme, nicht das Testen. Wiederholtes Testen und Ausbessern unausgereifter Programmteile kostet uns in der Summe viel Zeit. Diese Zeit ist besser in ein statisches Verstehen der Programmteile investiert. Auf gewisse Weise knnen uns Testflle doch helfen, ein Programm statisch zu verstehen: Testflle knnen hnlich wie Anwendungsflle zur Spezikation benutzt werden. Jeder Testfall setzt bestimmte Eingabewerte mit Ausgabewerten in Beziehung. Auch wenn Testflle niemals alle Flle, die in einem nichttrivialen Programm auftreten, abdecken knnen, so geben sie das gewnschte Programmverhalten fr die abgedeckten Flle doch ganz klar und unmissverstndlich vor. Durch geschickte Auswahl der Testflle und Abdeckung der Sonder- und Grenzflle lsst sich trotzdem eine recht vollstndige Beschreibung des gewnschten Verhaltens erzielen. 5.3.2 Testmethoden Unter dem Testen eines Programms oder Programmteils versteht man vor allem das Ausprobieren des Programms bzw. Programmteils. Das klingt zwar einfach, ist es in der Praxis aber keineswegs. Im Laufe der Zeit haben sich unzhlige Testmethoden entwickelt, die alle ihre Existenzberechtigung in bestimmten Bereichen haben. Um einen groben berblick ber die wichtigsten Testmethoden zu bekommen, klassizieren wir sie nach verschiedenen Kriterien. Viele Projekte setzen nacheinander folgende Teststufen ein: Unittest: Dabei testet man die Funktionalitt eines klar abgegrenzten Programmteils (das ist eine Unit, beispielsweise eine einzelne Klasse). Es wird berprft, ob die Methoden so wie geplant laufen und die erwarteten Ergebnisse liefern. Man testet Units getrennt voneinander, weil jede einzelne bersichtlicher ist als das ganze Programm und Ursachen fr aufgetretene Fehler daher leichter zu nden und beseitigen sind. Oft wird der Unittest durch eigens dafr geschriebenen Programmcode auf knstlichen Testdaten ausgefhrt. Integrationstest: Der Test berprft die korrekte Zusammenarbeit zwischen den Units, die zuvor schon ber Unittests einzeln getestet wurden. Schwerpunkte liegen auf den Schnittstellen zwischen den
336
5.3 Testen
Units sowie komplexeren Ablufen, welche die Funktionalitt mehrerer Units beanspruchen. Wie beim Unittest verwenden wir oft eigenen Testcode und knstliche Testdaten. Systemtest: Das gesamte System wird hinsichtlich der Erfllung aller geforderten funktionalen und nichtfunktionalen Eigenschaften berprft. Meist wird der Test auf einer (knstlichen) Testumgebung und auf knstlichen Testdaten ausgefhrt. Abnahmetest: Der Abnahmetest berprft, ob das gesamte System auch unter realen Bedingungen mit realen Daten seine Aufgaben erfllt. Nach Bestehen dieses Tests geht das System tatschlich in Betrieb. Diese Teststufen sind auch bei der Lsung von bungsaufgaben hilfreich. Den Integrations- und Systemtest wird man aufgrund der Kleinheit der Aufgaben jedoch zu einer Einheit verschmelzen, und der Abnahmetest entspricht der berprfung des Programms im Rahmen der Beurteilung. Abgesehen vom Abnahmetest werden diese Teststufen wiederholt ausgefhrt. Nach jeder nderung und neuerlichen Compilation ist ein Testlauf fllig. Meist werden fr jeden Testlauf dieselben Testflle und Testdaten verwendet, nur manchmal kommen neue Testflle und Testdaten hinzu. Diese Art von wiederholtem Test nennt man Regressionstest. Regressionstests berprfen, ob Testflle, die in einer frheren Programmversion keine Fehler aufdecken konnten, auch in spteren Versionen fehlerfrei durchlaufen. Damit sollen Fehler, die sich durch eine Programmnderung eingeschlichen haben, ohne Verzgerung aufgedeckt werden. Wegen der hugen Wiederholung der Testlufe erfolgt das Testen fast immer automatisiert, oft durch selbst geschriebene Testprogramme. Der Wissensstand ber die zu testenden Programmteile beeinusst die Testflle. Diesbezglich knnen wir drei Testarten unterscheiden: Black-Box-Test: Beim Black-Box-Test verwendet man keinerlei Wissen ber die interne Realisierung von Programmdetails. Testflle werden ausschlielich entsprechend dem gewnschten Verhalten des Programms anhand der Anforderungsdokumentation entworfen, hug von auf das Testen spezialisierten Personen und nicht von den Entwickler(inne)n des Systems. Black-Box-Tests werden vor allem fr Systemtests und Abnahmetests eingesetzt. White-Box-Test: Beim White-Box-Test basieren Testflle auf der internen Struktur des Programms. Wir entwickeln die Testflle zusammen
337
5 Qualittssicherung
mit dem Programm. Dabei sorgen wir dafr, dass beispielsweise jeder Programmzweig oder die Auswirkung jeder Anweisung durch einen Testfall abgedeckt ist, um zumindest alle groben Fehler entdecken zu knnen. Im weitesten Sinn entsprechen auch assert-Anweisungen mit eingeschalteter berprfung White-Box-Tests. White-Box-Tests werden vor allem fr Unittests und Integrationstests eingesetzt. Grey-Box-Test: Dabei entwickeln wir Testflle zur genauen Spezikation des Programms noch vor dessen Implementierung. Wie beim BlackBox-Text haben wir zu diesem Zeitpunkt noch keine Information ber die Programmstruktur. Da jedoch die Implementierung der Spezikation folgt, ergibt sich am Ende eine fast so gute Abdeckung aller Programmzweige wie beim White-Box-Test. Eine weitere Unterscheidungsmglichkeit ergibt sich durch die inhaltlichen Aspekte des Testens. Hier betrachten wir nur eine kleine Auswahl: Funktionaler Test: Er berprft die funktionalen Eigenschaften vor allem im Hinblick auf Korrektheit und Vollstndigkeit. Nichtfunktionaler Test: Damit berprft man nichtfunktionale Eigenschaften. Dazu zhlen viele berprfbare Aspekte aus den Bereichen Wartbarkeit, Gebrauchstauglichkeit und Zuverlssigkeit. Schnittstellentest: Er berprft, ob alle Komponenten eines Programms zusammenpassen und gemeinsam funktionieren. Oberchentest: Dieser Test bezieht sich auf die Verwendbarkeit und Funktionalitt der Benutzerschnittstelle. Stresstest: Ein Stresstest berprft das Verhalten eines Systems unter Ausnahmebedingungen. Es gibt zahlreiche Varianten wie den Crashtest, bei dem man versucht, das System zum Absturz zu bringen, und einen Lasttest, bei dem man das Verhalten eines (etwa durch viele gleichzeitige Benutzer) ber die Grenze belasteten Systems testet. Sicherheitstest: Er prft das System auf Sicherheitslcken. Diese Aufzhlung kann man beinahe endlos fortsetzen. Durch Tests kann man alle Aspekte berprfen, die einem wichtig genug erscheinen. Testen kostet Zeit und muss organisiert werden. Obwohl man wei, wie wichtig Testen ist, wird in vielen (vor allem kleinen) Projekten nur halbherzig getestet. Das fhrt leider zu schlechter Qualitt, auch deswegen,
338
5.3 Testen
weil man whrend der Programmkonstruktion das Gefhl hat, dass Qualitt gar nicht gefragt ist. Deshalb sollte man sich eine gute Teststrategie berlegen und von Anfang an durchziehen. Getestet werden sollte alles, was fr das Projekt wichtig ist. Wenn man bereits zu Beginn die zu berprfenden Aspekte kennt, wird man das Programm so konstruieren, dass diese Aspekte eine hohe Qualitt bekommen. Die Teststrategie kann im weiteren Sinn auch als eine Art von Spezikation betrachtet werden. Es schadet nicht, neben den durchorganisierten Regressionstests das unfertige Programm einfach einmal auf eine unbliche Art aufzurufen und zu schauen, was passiert. Solche zuflligen und scheinbar gar nicht organisierten Tests decken oft dizile Fehler auf, mit denen niemand gerechnet hat und die deswegen in den organisierten Tests nicht vorkommen. Daher ist es sinnvoll, auch zufllige Tests in die Teststrategie zu integrieren. 5.3.3 Laufzeitmessungen Auf den ersten Blick scheinen Laufzeitmessungen an Programmen sehr einfach zu sein: Man braucht den zu messenden Ablauf nur zu starten und mit der Stoppuhr zu messen, wieviel Zeit verstreicht, bis der Ablauf endet. Leider bekommen wir auf diese Weise keine zuverlssigen Ergebnisse, die Rckschlsse auf die Dauer des Ablaufs unter leicht genderten Bedingungen zulassen. Die gemessene Zeit hngt von zahlreichen Faktoren ab, beispielsweise Art und Menge der verarbeiteten Daten, anderer am Rechner gleichzeitig laufender Software und einer Unzahl an winzigsten, undurchschaubaren Details in der Hard- und Software. So kann es ausreichen, sich vor der Messung unter einem andern Namen (mit denselben Rechten und im Hintergrund laufenden Programmen) am Rechner anzumelden, um ganz andere Ergebnisse zu bekommen. Genaue Grnde fr derartige Eekte sind kaum zu nden. Mangels besserer Erklrung redet man sich gerne auf Cache-Eekte aus, also Unterschiede in der Laufzeit, die dadurch verursacht werden, dass der Prozessor (meist wegen genderter Speicheradressen) andere Datenmengen im Cache hlt. Fr bestimmte Programmstcke sind Laufzeitunterschiede durch solche Eekte bis zu einem Faktor zwei oder sogar mehr nicht auergewhnlich. Unter Linux lsst sich die Zeit mittels time messen: time javac [Link] In diesem Beispiel messen wir die Zeit fr das bersetzen eines einfachen Java-Programms. Das Ergebnis besteht aus mehreren Zeitangaben:
339
5 Qualittssicherung
Der real-Wert gibt an, wieviel Zeit vom Beginn bis zum Ende der Ausfhrung verstrichen ist, der user-Wert wieviel Zeit eines Prozessor-Kerns die eigentliche Programmausfhrung beansprucht hat, und der sys-Wert wieviel Zeit eines Prozessor-Kerns das Betriebssystem an Serviceleistungen fr die Programmausfhrung erbracht hat. Es fllt auf, dass die user-Zeit mehr als eine Sekunde ausmacht, die Ausfhrung aber schon nach weniger als einer Sekunde fertig war. Der Grund dafr liegt in den vier ProzessorKernen, die unser Rechner hat. Oensichtlich wurden Programmteile parallel ausgefhrt. Die Zeiten fr user und sys knnten zusammen bis zu viermal so lang sein wie fr real. Tatschlich waren die Prozessor-Kerne nur zu einem kleinen Teil ausgelastet. Die restliche Zeit haben die Kerne mit der Ausfhrung anderer Programme oder mit Warten verbracht. Wiederholte Zeitmessungen (auf demselben Rechner mit genau denselben Programmaufrufen) ergeben unterschiedliche Zeiten, beispielsweise eine real-Zeit zwischen etwa 0,7 und 1,3 Sekunden, eine user-Zeit zwischen 0.7 und 1,1 Sekunden und eine sys-Zeit zwischen 0,04 und 0,08 Sekunden. Diese Werte zeigen, wie gro die Laufzeitunterschiede schon unter unvernderten Bedingungen sind. Wenn wir die Bedingungen ndern, werden die Unterschiede noch deutlich grer. Um den Laufzeitunterschieden auf die Spur zu kommen, wollen wir einige Ursachen fr Verzgerungen betrachten. Latenz: Heute spielt die Kommunikation zwischen Rechnern sowie zwischen einzelnen Komponenten (CPU, Speicher, Festplatten, etc.) innerhalb eines Rechners eine fr die Ezienz entscheidende Rolle. Wenn bentigte Daten erst geholt werden mssen, entstehen Wartezeiten. Unter der Latenz (Verzgerung) versteht man die Zeit vom Senden bis zum Empfangen einer einfachen Nachricht (Einweglatenz ) oder vom Senden einer Anforderung von Daten bis zum Erhalt des (ersten) Datenpakets (Round-Trip-Time, RTT ). Je grer die Latenz ist, desto lnger muss man warten. Bandbreite: Die Bandbreite gibt an, wie viele Daten in einer bestimmten Zeiteinheit bertragen werden knnen. Durch bertragung grerer Blcke auf einmal steigt die Bandbreite, da man durch nur eine Anforderung viele Daten bekommt. Allerdings ist das nur sinnvoll,
340
5.3 Testen
wenn man alle diese Daten bentigt. In Anwendungen, in denen viele nebeneinander liegende Daten aus einer Quelle bertragen werden (beispielsweise Video-Streaming), kommt es auf die Bandbreite an. In anderen Anwendungen, in denen viele Daten von unterschiedlichen Adressen geholt werden, ist die Latenz von grerer Bedeutung. Feingranularer Parallelismus: Moderne Prozessoren steigern die Ezienz, indem sie mehrere aufeinanderfolgende Befehle gleichzeitig abarbeiten. Sie knnen auch kurze Wartezeiten berbrcken, indem sie Befehle, fr die bereits alle Daten vorhanden sind, vorziehen. Beides geht aber nur, wenn die Befehle nicht voneinander abhngen. Typischerweise ist diese Art des Parallelismus auf wenige Befehle begrenzt und kann nur kurze Verzgerungen ausgleichen. Thread-Parallelismus: Meist werden gleichzeitig mehrere Threads abgearbeitet. Muss ein Thread fr lngere Zeit auf Daten warten, wird whrend dieser Zeit ein anderer Thread ausgefhrt. Das Umschalten zwischen Threads ist mit Aufwand verbunden, sodass sich der Gesamtaufwand durch huges Umschalten merklich erhht. Zeiten, in denen ein Prozessor-Kern nur kurz (z.B. auf Daten aus dem Speicher) wartet, sind in den user und sys-Werten enthalten, nicht jedoch Wartezeiten, die durch Thread-Parallelismus ausgeglichen werden knnten. Unterschiedliche Wartezeiten wirken sich also nicht nur auf den real-Wert, sondern auch auf die user und sys-Werte aus. Um unterschiedliche Messergebnisse auszugleichen, ist es blich, Mittelwerte aus mehreren Messungen anzugeben. Diese Vorgehensweise suggeriert jedoch Objektivitt, die tatschlich meist nicht gegeben ist: Mehrfache Messungen unter denselben Bedingungen schalten ja nur ganz wenige zufllige Einussgren aus, whrend andere auch in den Mittelwerten enthalten sind. Eigentlich msste man viele Messungen unter unterschiedlichen Bedingungen (wie verschiedenen Datenmengen, Rechnerbelastungen, Hardware-Architekturen, Betriebssystemen, Compilern, Interpretern) durchfhren und die Ergebnisse systematisch gegenberstellen. Ein so hoher Aufwand ist in der Praxis jedoch fast nie gerechtfertigt. Ein huger Fehler besteht darin, die Laufzeit fr eine bestimmte Datenmenge zu messen und linear auf eine andere Datenmenge hochzurechnen. Das funktioniert nicht, weil die meisten Algorithmen einen nicht-linearen Aufwand haben. Oft misst man nicht nur die Laufzeit eines Algorithmus, sondern mehrerer Algorithmen mit unterschiedlichem Aufwand, die zu-
341
5 Qualittssicherung
sammen das Programm ergeben. Daher ist es schwierig, Laufzeiten aus mehreren Messungen hochzurechnen. Um zuverlssige Aussagen ber die Laufzeit bei Datenmengen bestimmter Gre zu bekommen, muss man die Laufzeit tatschlich mit Datenmengen dieser Gre messen. Auerdem muss die Datenmenge realistisch sein. Reale Daten fhren zu ganz anderen Ergebnissen als Zufallszahlen oder mehrfach kopierte kleine Datenmengen. Zuverlssige Laufzeitmessungen erfordern viel Spezialwissen. Meist gibt man sich mit groben Nherungswerten zufrieden. Man will etwa nur feststellen, ob Antwortzeiten blicherweise im erwarteten Bereich liegen. Fr manche Systeme, sogenannte Echtzeitsysteme ist das zeitliche Verhalten jedoch kritisch und von berragender Bedeutung. Man unterscheidet weiche von harten Echtzeitsystemen. Erstere ndet man z.B. in Spielen, wo Berechnungen nur eine gewisse Zeit dauern drfen, weil sonst Darstellungsfehler auftreten oder die gewnschte Frame-Rate nicht erreicht wird. Gelegentliche Zeitberschreitungen sind aber tolerierbar. In harten Echtzeitsystemen haben Zeitberschreitungen dagegen fatale Folgen. Man kann sich leicht ausmalen, was passiert, wenn die Lenkung oder Bremse in einem Fahrzeug verzgert reagiert. Sicherheitskritische Echtzeitsysteme setzen zahlreiche Verfahren und Techniken ein, um Ausflle und Verzgerungen zu vermeiden. Mit einfachen Laufzeitmessungen ist es nicht mehr getan. Vielmehr kommen Kombinationen aus Laufzeitmessungen und Beweisverfahren zum Einsatz, die Rechtzeitigkeit garantieren sollen. Es werden auch gleichzeitig mehrere redundante Lsungen derselben Aufgabe berechnet, um im Falle des Versagens einer Berechnung doch noch rechtzeitig ein Ergebnis zur Verfgung zu haben. Harte Echtzeitsysteme werden so gut wie nie in Java geschrieben, da man nher an der Hardware bleiben und mglichst wenig von Einssen durch das Laufzeitsystem der Programmiersprache abhngig sein mchte. Hauptschlich kommt die Programmiersprache C zum Einsatz. Jeder dieser Einussfaktoren (Hardwarenhe, Redundanz, Beweisverfahren) bewirkt, dass die Entwicklung harter Echtzeitsysteme viel aufwendiger ist als die nicht zeitabhngiger Programme oder weicher Echtzeitsysteme.
342
Listing 5.4: Fehlerhaftes Programm 1 public class Rec { 2 public static void main(String[] args) { 3 rec(2); 4 } 5 private static int rec(int x) { 6 assert x > 0 : "x = " + x; 7 // [Link]("x = " + x); 8 return rec(x - 1); 9 } 10 }
Debuggen. Wegen der gigantischen Anzahl an Programmpfaden ist es viel schwieriger, ein Programm durch Nachvollziehen aller mglichen dynamischen Ablufe zu verstehen, als sich ein statisches Programmverstndnis anzueignen. Das Nachvollziehen ist nur sinnvoll um sich einzelne Programmpfade, auf denen Fehler passieren, nher zu betrachten. 5.4.1 Stack-Traces und Debug-Output Listing 5.4 zeigt ein fehlerhaftes Programm: Der Methode rec fehlt eine Abbruchbedingung, sodass die Rekursion niemals terminiert. Diesen Fehler kann man leicht statisch verstehen. Trotzdem verwenden wir dieses Beispiel als Basis fr das Nachvollziehen des Programmablaufs. Nach Start des Programms durch java Rec bekommen wir die Fehlermeldung Exception in thread "main" [Link] gefolgt von einer langen Reihe von Zeilen at [Link]([Link]). hnliche Fehlermeldungen bekommen wir immer, wenn das Laufzeitsystem einen Fehler entdeckt und als Ausnahme (engl. Exception ) zurckmeldet. Wie wir in Abschnitt 5.5 sehen werden, knnen die meisten Ausnahmen im Programm abgefangen und behandelt werden. In unserem Beispielprogramm machen wir das nicht, und ein Abfangen dieser Art von Ausnahmen ist generell sinnlos. Die unbehandelte Ausnahme fhrt zum Programmabbruch und zur Fehlermeldung. Der Inhalt der Fehlermeldung gibt uns wichtige Hinweise zur Fehlerursache: Wie der Name StackOverflowError des Typs der Ausnahme schon vermuten lsst, wurde die Ausnahme ausgelst, weil der implizit vom Laufzeitsystem verwendeten Stack nicht gro genug war um alle geschachtelten Methodenaufrufe zu enthalten. Auf die-
343
5 Qualittssicherung
Exception in thread "main" [Link]: x = 0 at [Link]([Link]) at [Link]([Link]) at [Link]([Link]) at [Link]([Link])
sem Stack wird bei jedem Methodenaufruf ein Eintrag gemacht und am Ende der Ausfhrung der Methode wieder entfernt. Der Stack enthlt also stets Informationen zu allen geschachtelten Methodenaufrufen zu einem bestimmten Zeitpunkt; der oberste Stackeintrag entspricht der gerade ausgefhrten Methode, alle anderen Eintrge entsprechen Methodenausfhrungen, die auf ein Ergebnis eines geschachtelten Aufrufs warten. Die Fehlermeldung enthlt den sogenannten Stack-Trace, welcher wesentliche Teile der zum Zeitpunkt der Ausnahme im Stack enthaltenen Information umfasst. Die zweite Zeile zeigt die Programmstelle, an der die Ausnahme aufgetreten ist: at [Link]([Link]) steht fr Methode rec in Klasse Rec, deniert in der Datei [Link], und der Fehler ist bei Ausfhrung einer Anweisung in der Nhe von Zeile 8 entstanden. Die nchste Zeile gibt an, wo diese Methode aufgerufen wurde zufllig die gleiche Stelle und so weiter bis zur Methode main. Tatschlich wird in diesem Beispiel nur der oberste Teil des gesamten Stack-Trace ausgegeben, weil der sehr groe Trace nicht mehr lesbar wre. Der Stack-Trace macht klar, was passiert ist: Dieselbe Methode rec wurde immer wieder von derselben Programmstelle aus aufgerufen, bis der Stack voll war. Das ist schon ein sehr deutlicher Hinweis auf die Fehlerursache. Der Stack-Trace wird nach jedem unvorhergesehenen Programmabbruch ohne weiteres Zutun ausgegeben. Es liegt an uns, die Informationen zum Programmablauf, der zum Abbruch gefhrt hat, richtig zu interpretieren. Mit etwas Geschick ist das gar nicht schwer. Allerdings kommt es vor, dass der tatschliche Programmablauf sich von dem erwarteten unterscheidet. Beispielsweise htten wir erwartet, dass rec nur zweimal aufgerufen wird, weil die Zusicherung verlangt, dass x grer 0 ist. Um diesem Fehler auf die Spur zu kommen, schalten wir die berprfung von Zusicherungen ein. Bei Verwendung einfacher assert-Anweisungen bekommen wir bei Verletzung der Zusicherungen zwar eine Fehlermeldung mit einem Stack-
344
Trace, aber wir wissen dann noch immer nicht, welcher Wert von x den Fehler verursacht hat. Genau um in solchen Situationen mehr Informationen zu bekommen, gibt es eine erweiterte Form von assert-Anweisungen, die nach der Zusicherung einen Doppelpunkt und einen String enthlt. Der zustzliche String wird als Teil der Fehlermeldung ausgegeben. Abbildung 5.5 zeigt die Fehlermeldung nach Aufruf von java -ea Rec. Das zustzliche Argument der assert-Anweisung in Listing 5.4 setzt einen String aus "x =" und dem Wert von x zusammen. Die Fehlermeldung enthlt den resultierenden String "x = 0", also wissen wir, dass x den Wert 0 hat. Auf diese Weise knnen wir die Inhalte beliebiger Variablen als Teil von Fehlermeldungen ausgeben lassen. Zusammen mit dem StackTrace erhalten wir so recht viel Information ber den Programmzustand zum Zeitpunkt des Programmabbruchs. Manchmal reicht auch die zustzliche Information ber den Programmzustand, die wir nach einer fehlgeschlagenen berprfung von Zusicherungen bekommen, nicht aus, um die Fehlerursache zu nden. Wir bentigen auch Informationen ber Programmzustnde vor dem Programmabbruch. Solche Information knnen wir ber ganz normale Ausgaben erreichen, beispielsweise indem wir die in Listing 5.4 auskommentierte Anweisung ausfhren lassen. Damit bekommt man rasch ein Verstndnis dafr, wie sich die Werte von x in jedem Rekursionsschritt ndern. Solche Debug-Anweisungen gehren nicht zum eigentlichen Programm, und man darf nicht vergessen, sie zu entfernen oder auszukommentieren, wenn man die Fehlerursache gefunden hat. Normale Programmlufe sollen den Debug-Output ja nicht enthalten. Alternativ dazu kann man DebugAnweisungen als bedingte Anweisungen im Programm belassen, beispielsweise in der Form if(debug) [Link](...);. Die Boolesche Variable debug setzt man vielleicht entsprechend einem Kommandozeilenargument, das als Eintrag im Array args an die Methode main bergeben wird. Dadurch kann man den Debug-Output beim Programmaufruf auf hnliche Weise ein- und ausschalten, wie man das mit der berprfung von assert-Anweisungen macht. In greren Programmen mchte man Debug-Output nicht nur ein- und ausschalten, sondern man bentigt viel genauere Kontrolle darber, in welchem Bereich (Klasse, Paket, etc.) welche Art von Debug-Information ausgegeben werden soll. Mit Hilfe der hier gezeigten Technik lsst sich das ber mehrere Boolesche Variablen problemlos bewerkstelligen. Debug-Output, der in den normalem Output des Programms eingestreut ist, kann sehr strend sein. Man kann das vermeiden, indem man
345
5 Qualittssicherung
Debug-Output statt in die Standard-Ausgabe in speziell dafr vorgesehene Dateien schreibt. Entsprechende Dateien nennt man Logdateien oder Protokoll-Dateien, weil in ihnen alle Ereignisse einer bestimmten Art protokolliert werden. Das Schreiben von Logdateien strt den normalen Programmablauf kaum. Gelegentlich schreibt man alle wichtigen Ereignisse in jedem Programmlauf (nicht nur beim Debuggen) in Logdateien, um fr den Fall eines spter entdeckten Fehlers leichter nachvollziehen zu knnen, wodurch der Fehler verursacht wurde. Betriebssysteme greifen hug auf Logdateien zurck. So kann man beispielsweise auch im Nachhinein leicht feststellen, wann sich wer von wo auf einem Rechner angemeldet hat. 5.4.2 Debugger Ein Debugger ist ein Werkzeug, das uns dabei hilft, den dynamischen Programmablauf durch folgende Funktionalitt zu vergegenwrtigen: Zur Unterbrechung des Programmablaufs an einer bestimmten Stelle, beispielsweise am Anfang einer Methode oder einer Anweisung im Programm, setzen wir einen Breakpoint an diese Stelle. Sobald der Programmuss diese Stelle erreicht, hlt die Programmausfhrung an und gibt uns Gelegenheit zu weiteren Aktionen. Wir knnen den Programmuss auch bei Zugri auf eine bestimmte Variable oder bei nderung des Wertes der Variablen unterbrechen lassen. Dazu setzen wir einen Breakpoint auf die Variable. Es knnen mehrere Breakpoints gleichzeitig aktiv sein und bewirken, dass die Programmausfhrung unterbrochen wird, sobald einer der Breakpoints erreicht ist. Whrend die Programmausfhrung unterbrochen ist, knnen wir den Programmzustand durch Betrachtung von Variableninhalten untersuchen. Es ist auch mglich, Werte von Variablen zu ndern und neue Breakpoints zu setzen bzw. existierende zu entfernen. Die unterbrochene Programmausfhrung kann auch wieder fortgesetzt werden. Neben der normalen Programmausfhrung mit Unterbrechung bei Breakpoints knnen wir auch veranlassen, dass nur ein einzelner Schritt im Programm ausgefhrt und die Ausfhrung danach sofort wieder unterbrochen wird. Fr den Fall, dass der Schritt einen Methodenaufruf beinhaltet, knnen wir whlen, ob wir die Ausfhrung bereits zu Beginn dieser Methode wieder anhalten wollen (Step-Into) oder erst nach Rckkehr aus der Methode (Step-Over ).
346
Meist verwenden wir integrierte Entwicklungsumgebungen wie Eclipse und NetBeans, die einen mit wenigen Mausklicks aufruf- und bedienbaren Debugger enthalten. Diese Funktionalitt erlaubt uns auf komfortable Weise einen detaillierten Blick in das laufende Programm. Ein Debugger dient hauptschlich der Suche nach Fehlerursachen. Man kann ihn aber auch als Hilfsmittel beim Programmierenlernen einsetzen um die schrittweise Vernderung bestimmter Variablenwerte nachzuvollziehen. Mit ausreichend Programmiererfahrung wird das kaum mehr gemacht, weil diese dynamische Mglichkeit, den Programmablauf nachzuvollziehen, einen im Vergleich zu den gewinnbaren Erkenntnissen zu groen Aufwand verursacht. Ein durch Lesen des Programms gewonnenes statisches Programmverstndnis ist meist wertvoller, da es sich nicht nur auf einen Ablauf bezieht, sondern auf alle Ablufe auf einmal. Beim Debuggen (also der Suche nach Fehlerursachen) ist es trotz der Einfachheit in der Bedienung des Debuggers manchmal recht schwierig, genau jene Ablufe im Programm zu sehen zu bekommen, die uns die Ursache des Fehlers deutlich machen. Folgende Probleme treten auf: Fehler passieren irgendwo in einem lange laufenden Programm, nicht gleich am Anfang. Wir wissen oft nicht, auf welche Programmstellen und Variableninhalte wir achten mssen. Sogar wenn wir wissen, dass der gesuchte Fehler in einer bestimmten Methode auftritt, so wissen wir meist nicht, im wievielten Methodenaufruf bzw. unter welchen Bedingungen er passiert und wo wir Breakpoints setzen mssen. Durch Verndern von Variablenwerten knnen wir Berechnungen abkrzen und rascher an die Stelle kommen, die uns interessiert. Das kann die Fehlersuche beschleunigen. Allerdings wirkt sich die nderung mglicherweise auch ganz anders auf das Programmverhalten aus, sodass der gesuchte Fehler gar nicht oder an einer ganz anderen Stelle auftritt als bei einem normalen Programmlauf. Gelegentlich wirkt sich die Verwendung des Debuggers auch ohne Vernderung von Variablenwerten auf das Programmverhalten aus. Klarerweise verndert sich die Laufzeit. Diese nderung kann in den Ergebnissen sichtbar werden (z.B. wenn das Programm die Zeit misst und abhngig von der Berechnungsdauer andere Zweige ausfhrt). Auswirkungen eines Fehlers zeigen sich oft an anderen Stellen als dort, wo er wirklich passiert ist. Wenn wir uns ganz auf die Stelle
347
5 Qualittssicherung
konzentrieren, an der wir die Auswirkungen sehen, kommen wir dem Fehler nur sehr schwer auf die Spur. Mangels genauerer Informationen beginnen wir die Suche nach einer Fehlerursache dort, wo sich der Fehler zeigt. Wir werden wahrscheinlich feststellen, dass an dieser Stelle eine Variable einen unerwarteten Wert hat oder ein Programmzweig ausgefhrt wird, der eigentlich nicht ausgefhrt werden sollte. Das gibt uns Anhaltspunkte fr die weitere Suche. Wir werden also die Stelle suchen, an der die Variable ihren Wert bekommen hat bzw. an der in den aktuellen Programmzweig verzweigt wurde. Dazu muss der Debugger in der Regel das Programm nocheinmal von vorne durchlaufen, weil wir die Programmstellen, die uns interessiert htten, schon verpasst haben. Die Untersuchung dieser Stelle im Programm fhrt wahrscheinlich wieder zu einer noch frheren Stelle im Programm, an der wir suchen mssen, und so weiter. Dadurch gestaltet sich die Suche nach der Fehlerursache oft sehr langwierig. Seit kurzem gibt es Debugger, die es uns nicht nur erlauben, die Berechnung Schritt fr Schritt mitzuverfolgen, sondern auch zu frheren Programmzustnden zurckzukehren. Diese Debugger knnen die Fehlersuche etwas beschleunigen. Das Grundproblem, nmlich dass wir nach der sprichwrtlichen Nadel im Heuhaufen suchen, bleibt jedoch bestehen. Auch noch so gute Werkzeuge knnen die menschliche Erfahrung und Intuition bei der Suche nach Fehlersuchen nicht ersetzen. Ein weiteres Problem erschwert das Debuggen zustzlich: In komplexeren Programmen ist uns oft nicht bewusst, welchen Wert eine bestimmte Variable zu einem bestimmten Zeitpunkt haben und welcher Programmzweig durchlaufen werden soll. Solche Information mssen wir uns beschaffen, indem wir entsprechende Programmteile statisch genau zu verstehen versuchen. Manchmal werden wir in die Irre geleitet und nehmen einen Fehler an, wo gar keiner ist, oder betrachten einen falschen Wert als richtig. Dadurch geht viel Zeit verloren. 5.4.3 Eingrenzung von Fehlern Die Suche nach Fehlerursachen hlt stets neue berraschungen bereit. Auch mit sehr viel Programmiererfahrung stehen wir immer wieder vor bisher unbekannten Situationen und entdecken Fehler, mit denen niemand auch nur im Entferntesten gerechnet htte. Es kommt auf viel Fingerspitzengefhl und Intuition sowie auf die Fhigkeit an, aus gewohnten Denkmustern auszubrechen.
348
Trotz der Unvorhersehbarkeit der Suche geht man fast immer nach demselben Schema vor: Durch Sammeln von Fakten grenzt man die Programmstellen immer weiter ein, bis man die Ursache des Fehlers durch statisches Verstehen des verbliebenen Programmcodes durchblickt. Erst wenn man am Programmcode (nicht nur durch Verfolgen des Programmablaufs) sieht, welche Situationen dazu fhren, dass sich Fehler zeigen, hat man eine mgliche Ursache verstanden. Beim Sammeln von Fakten durchlaufen wir zyklisch folgende Schritte: Orientierung: In einem ersten Schritt mssen wir uns zur Orientierung einen berblick darber verschaen, in welchem Bereich der Fehler auftritt. Vor allem mssen wir den entsprechenden Teil des Programmcodes betrachten und zu verstehen versuchen. Dabei sehen wir, wie die Methoden und Variablen zusammenhngen und mit dem Auftreten des Fehlers in Verbindung stehen. Falls wir aus dem Programmcode nicht genug Informationen herausnden, hilft die Betrachtung des Programmzustandes mit einem Debugger weiter. Hypothese: Wir stellen eine Hypothese darber auf, welche mglichst kleine und klar abgegrenzte Menge an Variablen, Methoden und Anweisungen an der Entstehung des Fehlers beteiligt sein oder zumindest Hinweise auf die Fehlerursache geben knnte. Die Zustnde dieser Variablen und das Verhalten dieser Methoden und Anweisungen mssen wir nher betrachten. Es liegt in der Natur einer Hypothese, dass wir nicht wissen, ob der Fehler tatschlich dort liegt, wo wir in vermuten. Wir mssen also raten. Mit guten Programmkenntnissen (aufgrund der Orientierung) treen wir dennoch oft eine gute Wahl. Planung: Um die Hypothese zu besttigen oder zu widerlegen mssen wir die Entwicklung der Programmzustnde in den betroenen Bereichen feststellen. Zunchst legen wir uns einen Plan zurecht, wie wir interessante Ausschnitte aus den Programmzustnden leicht verfolgen knnen. Beispielsweise identizieren wir Programmstellen, an denen wir Werte von Variablen in eine Datei ausgeben lassen. Stattdessen knnen wir auch Breakpoints fr den Debugger festlegen, an denen wir bestimmte Variablenwerte oder Programmablufe durch schrittweise Ausfhrung nher betrachten wollen. Durchfhrung: Nun sammeln wir die Daten wie geplant. Falls Probleme auftreten, mssen wir zurckgehen und die Planung (falls sich die
349
5 Qualittssicherung
praktische Ausfhrung der Plne nicht einfach genug machen lsst) oder die Hypothese (falls die Durchfhrung an einer nicht haltbaren oder zu wenig stark fokussierten Hypothese scheitert) berarbeiten. Auswertung: Schlielich werten wir die im vorigen Schritt gesammelten Daten aus. Bei groen Datenmengen kann das recht aufwendig sein und die Hilfe von (manchmal auch selbst erstellten) Werkzeugen oder Suchfunktionen im Editor erfordern. Wir stellen fest, bis wo welche Daten noch unseren Erwartungen entsprechen und ab wann sie falsch sind. Daraus gewinnen wir Erkenntnisse ber die Stelle im Programm, an der die Fehlerursache liegt, und wie wir beginnend mit der Orientierung die Fehlerquelle weiter einschrnken knnen. Manchmal stellt sich aber auch heraus, dass die gesammelten Daten keine Fehler zeigen oder von Anfang an nicht unseren Erwartungen entsprechen; dann ist die Hypothese falsch, und wir mssen mit einer neuen Hypothese weitersuchen. Gelegentlich sind einfach nur die Daten selbst mangelhaft; dann mssen wir zurck zur Planung und Durchfhrung, um brauchbare Daten zu erhalten. Diese Schritte sind nicht immer klar voneinander getrennt. In einfacheren Fllen kann die Auswertung bereits whrend der Durchfhrung erfolgen, beispielsweise whrend wir Variablenwerte im Debugger betrachten. Wir knnen auch rasch Rckschlsse ziehen, die sich unmittelbar auf die Hypothese auswirken, sodass wir gleich im selben Durchlauf durch den Debugger die Aufmerksamkeit auf andere Variablenwerte lenken. Manche Fehlerursache ist schnell gefunden und beseitigt. Beispielsweise wird der Compiler einen Fehler melden, wenn der Name einer Variablen falsch geschrieben ist. Dieser Fehler fllt beim sorgfltigen Lesen der Fehlermeldung sofort auf und ist rasch behoben. Generell gilt, dass Ursachen fr Fehler, die der Compiler meldet, viel rascher zu nden sind als Fehler, die erst beim Testen entdeckt werden. Zum einen liegt das an informativen Fehlermeldungen, zum anderen an der Art der Fehler. Compiler verstehen das Programm ja nicht und erkennen daher nur relativ einfache Inkonsistenzen im Programm, die wir mit einiger Programmiererfahrung (und mit viel Aufwand) auch selbst ohne grere Probleme erkennen knnten. Viele Fehler, die erst beim Testen auftreten, haben Ihren Ursprung in inhaltlichen Missverstndnissen, und ihre Ursachen sind dadurch wesentlich schwieriger zu nden und noch schwieriger zu beseitigen. Fr den ezienten Umgang mit kleinen Fehlern ist es wichtig, Fehlermeldungen richtig zu lesen. Der Compiler kann nur Inkonsistenzen erkennen,
350
5.5 Ausnahmebehandlung
aber keine Fehlerursachen. Trotzdem klingen manche Fehlermeldungen so, als ob der Compiler wsste, was falsch ist. Beispielsweise meldet der Compiler bei inkompatiblen Typen, dass eine Instanz von int verlangt wird, aber eine Instanz von String im Programmcode gefunden wurde. Keinesfalls drfen wir diese Meldung als Anweisung fr das Ausbessern des Fehlers missverstehen und ohne weitere Prfung die Instanz von String durch eine Instanz von int ersetzen. Der Compiler hat nur eine zufllige Annahme getroen. Um die wirkliche Ursache des Fehlers zu nden mssen wir selbst den Programmcode betrachten, die Inkompatibilitt verstehen und schlielich beseitigen. Meist ist das leicht mglich. Das Debuggen ist sehr lehrreich. Man lernt dabei vor allem, auf welche mglichen Gefahren man beim Programmieren achten muss. Es ist zwar nicht ausgeschlossen, dass man denselben Fehler mehrfach macht, aber die Wahrscheinlichkeit dafr wird mit der Erfahrung kleiner. Es soll nocheinmal darauf hingewiesen werden, dass die Qualitt eines Programms durch das Debuggen, also die Suche nach Fehlerquellen und deren Beseitigung, nur wenig verbessert wird. Fr die Qualitt ist das statische Verstehen des Programms viel wichtiger. Das Finden von Fehlerursachen hilft uns jedoch dabei, das Programm besser statisch zu verstehen. In diesem Zusammenhang ist es nicht verwunderlich, dass das Finden unerwarteter Fehlerursachen, auf die wir beim Lesen des Programmcodes nicht achten, am meisten zur Qualittsverbesserung beitrgt. Gerade diese Fehlerursachen sind aber am schwersten zu nden.
5.5 Ausnahmebehandlung
Wenn der Java-Interpreter whrend der Programmausfhrung einen Fehler entdeckt, wirft er eine Ausnahme (Exception). Aber auch spezielle Anweisungen werfen Ausnahmen. Das Werfen einer Ausnahme unterbricht den normalen Programmuss und leitet eine Ausnahmebehandlung ein (Exception-Handling ). Wenn im Programm vorgesehen, wird die geworfene Ausnahme abgefangen und ein alternativer Programmzweig als Ersatz fr den unterbrochenen Zweig ausgefhrt, sodass das Programm auch im Fehlerfall weiterlaufen kann. Ohne Abfangen der Ausnahme wird das Programm abgebrochen, so wie in Abschnitt 5.4.1 beschrieben. Zunchst betrachten wir, wie vom System geworfene Ausnahmen in Java abgefangen werden knnen. Danach beschftigen wir uns mit selbstdenierten Ausnahmen und praktischen Aspekten im Umgang mit Ausnahme-
351
5 Qualittssicherung
fllen einschlielich dem Aufrumen, also der Beseitigung von berresten abgebrochener Programmausfhrungen. 5.5.1 Abfangen von Ausnahmen Es gibt zahlreiche Grnde, warum ein Java-Interpreter die Programmausfhrung wegen eines Fehlers nicht fortsetzen kann. Beispielsweise wirft er eine Ausnahme vom Typ ArrayIndexOutOfBoundsException wenn versucht wird, auerhalb der Arraygrenzen auf ein Array zuzugreifen, eine vom Typ NullPointerException wenn versucht wird, eine Nachricht an null zu schicken, eine vom Typ ArithmeticException wenn eine Zahl durch 0 dividiert werden soll, eine vom Typ AssertionError wenn die Bedingung in einer assert-Anweisung nicht erfllt ist, und eine vom Typ OutOfMemoryError bzw. StackOverflowError wenn nicht genug Speicher fr ein neues Objekt bzw. einen weiteren Methodenaufruf vorhanden ist. Diese und hnliche Fehler lassen sich trotz Typberprfungen und sorgfltiger Programmierung nicht gnzlich ausschlieen. Geworfene Ausnahmen werden als ganz normale Objekte dargestellt. Alle Typen dieser Objekte erweitern die vordenierte Klasse Throwable. Diese Klasse stellt eine Reihe von Methoden bereit um etwas ber die Ausnahme erfahren zu knnen. Die Methode printStackTrace() gibt beispielsweise einen Stack-Trace aus genau den, den wir bei einem Programmabbruch aufgrund der Ausnahme zu sehen bekommen. Wenn wir nichts unternehmen, wird die Programmausfhrung nach dem Werfen einer Ausnahme abgebrochen und der Stack-Trace ausgegeben. Um das zu vermeiden knnen wir geworfene Ausnahmen abfangen. Dazu verwenden wir Blcke der Form try{...} catch(T e){...}, wobei der try-Block eine beliebige Sequenz von Anweisungen enthlt, die wie alle anderen Anweisungen ausgefhrt werden. Wird jedoch in irgendeiner Anweisung dieser Sequenz eine Ausnahme vom Typ T (oder einem Untertyp von T) geworfen, so wird als Ersatz fr den try-Block der catchBlock ausgefhrt. Die Ausfhrung des try-Blocks wird vorher an der Stelle abgebrochen, an der die Ausnahme geworfen wird (wie jede Ausfhrung beim Werfen einer Ausnahme abgebrochen wird). Innerhalb des catch-Blocks kann auf das Objekt e, das die abgefangene Ausnahme darstellt, wie auf einen formalen Parameter zugegrien werden. Daher hnelt catch(T e){...} syntaktisch der Denition einer Methode mit einem Parameter. Solche Blcke nennt man Exception-Handler. Nach erfolgreicher Beendigung eines Exception-Handlers werden die darauf folgenden
352
5.5 Ausnahmebehandlung
Listing 5.6: Veranschaulichung des Abfangens einer geworfenen Ausnahme 1 public class ExceptionTest { 2 public static void main(String[] args) { 3 try { 4 for (int i = 0; i < 3; i++) 5 [Link](args[i]); // Achtung: 6 } 7 catch(ArrayIndexOutOfBoundsException ex) { // gefhrlich! 8 [Link]("ERROR: Zu wenige Argumente!"); 9 } 10 catch(Exception ex) { // OK 11 [Link]("ERROR (abgefangen):"); 12 [Link](); 13 } 14 [Link]("Nichts von einer Ausnahme zu sehen!"); 15 } 16 }
Anweisungen ganz normal ausgefhrt, so als ob keine Ausnahme geworfen worden wre. Wird im try-Block keine Ausnahme geworfen, so wird auch kein Exception-Handler ausgefhrt. Auf andere Ausnahmen als jene vom im Exception-Handler genannten Typ T und allen Untertypen von T wirkt sich ein Exception-Handler nicht aus; sie werden nicht abgefangen. Das Beispiel in Listing 5.6 veranschaulicht das Abfangen von Ausnahmen. In der Methode main in ExceptionTest nehmen wir an, dass args mindestens drei Elemente hat, also java ExceptionTest mit mindestens drei Argumenten aufgerufen wurde. Falls das Array weniger Elemente enthlt, wird beim ersten Zugri auf den ersten nicht mehr erlaubten Index eine Ausnahme ArrayIndexOutOfBoundsException geworfen und im ersten Exception-Handler abgefangen. Nach einem Programmaufruf werden daher die ersten drei Argumente bzw. bei weniger Argumenten die Argumente und eine Fehlermeldung ausgegeben, in jedem Fall gefolgt von der Zeile Nichts von einer Ausnahme zu sehen!, die am Ende von main ausgegeben wird. In diesem Beispiel ist der try-Block mit zwei Exception-Handlers versehen, die Ausnahmen unterschiedlicher Typen abfangen. Im Allgemeinen knnen wir beliebig viele Exception-Handlers haben. Der zweite und jeder weitere Exception-Handler kommt zum Tragen, wenn die geworfene Ausnahme eine Instanz des Typs von diesem Exception-Handler ist, aber
353
5 Qualittssicherung
keine Instanz der Typen von den davor stehenden Exception-Handlers. Die Klasse Exception ist der Obertyp aller Typen von Ausnahmen, die sinnvoll abgefangen werden knnen. Der zweite Exception-Handler im Beispiel ist dafr gedacht, jede abfangbare Ausnahme (auer der bereits abgefangenen Ausnahme ArrayIndexOutOfBoundsException) abzufangen und den Stack-Trace ex auszugeben, ohne jedoch das Programm gleich zu beenden. Wie das Beispiel zeigt, kann ein einziger Exception-Handler viele unterschiedliche Arten von Ausnahmen abfangen solche vom im Exception-Handler bezeichneten Typ sowie allen Untertypen davon. Nicht fr alle Arten von Ausnahmen ist ein Abfangen sinnvoll. Von der Klasse Throwable sind die beiden Klassen Exception und Error direkt abgeleitet. Fr Instanzen von Exception ist das Abfangen sinnvoll, fr jene von Error, die nur bei wirklich schwerwiegenden Problemen geworfen werden, jedoch nicht. Ein typisches Beispiel fr einen Untertyp von Error ist StackOverflowError. Der Versuch, eine Ausnahme dieses Typs abzufangen, kann nur wieder zum Werfen einer Ausnahme fhren, da fr eine sinnvolle Fortsetzung der Programmausfhrung nicht genug Speicherplatz am Stack vorhanden ist. blicherweise erkennt man Untertypen von Error daran, dass deren Name mit Error endet. Beispielsweise ist auch AssertionError eine Art von Ausnahme, die nicht abgefangen werden soll (obwohl dies technisch mglich wre), weil die Verletzung einer Zusicherung auf einen inkonsistenten Programmzustand hindeutet. Beim Abfangen einer solchen Ausnahme wrden weitere Berechnungen wahrscheinlich auf der Basis falscher Daten erfolgen. Falsche Ergebnisse eines Programms zeigen hug viel schlimmere Auswirkungen als ein Programmabbruch. Nach dem Werfen einer Ausnahme wird der passende Exception-Handler auf folgende Weise gesucht: Wurde beim Werfen gerade ein try-Block mit passendem Exception-Handler ausgefhrt, so wird die Programmausfhrung gleich mit diesem Handler fortgesetzt. Gibt es jedoch lokal keinen passenden Exception-Handler, aber einen umgebenden try-Block mit passendem Exception-Handler, so wird mit diesem fortgesetzt. Bei mehreren ineinander geschachtelten try-Blcken wird also von allen damit verbundenen Exception-Handlern der innerste gewhlt, dessen Typ mit dem der Ausnahme bereinstimmt. Hug ndet man in einer Methode berhaupt keinen passenden Exception-Handler. In diesem Fall wird die Suche nach einem Exception-Handler im Aufrufer der Methode fortgesetzt (so als ob die Ausnahme beim Aufruf der Methode geworfen worden wre), dann in dessen Aufrufer und so weiter. Die Ausnahme wird zum Aufrufer
354
5.5 Ausnahmebehandlung
weitergeleitet oder propagiert (nach engl. to propagate sich fortpanzen). Der erste passende Exception-Handler kommt zum Zug. An der Stelle, an der beim Programmstart main aufgerufen wurde, wird schlielich jede Ausnahme abgefangen und der Stack-Trace ausgegeben. Wenn man eine Ausnahme abfngt, wei man normalerweise nicht sicher, woher die Ausnahme stammt. Das ist ist ein huger Fehler im Umgang mit Ausnahmen. Beispielsweise nehmen wir in Listing 5.6 an, dass eine durch den ersten Exception-Handler abgefangene Ausnahme beim Zugri auf args[i] innerhalb des try-Blocks geworfen wurde. Diese Annahme wird zwar meistens zutreen, muss aber nicht immer stimmen. Eine ArrayIndexOutOfBoundsException knnte auch whrend der Ausfhrung von println geworfen und bis zur Methode main weitergeleitet werden sein. Um die Quelle der Ausnahme festzustellen, mssten wir den Stack-Trace analysieren. ber die Methoden von Throwable ist das machbar, aber aufwendig. Daher wird meist darauf verzichtet. Annahmen wie im ersten Exception-Handler in Listing 5.6 sind gefhrlich, und von der Verwendung dieser Programmiertechnik ist daher stets abzuraten. Statt diese Ausnahme abzufangen sollten wir besser mittels ifAnweisung zwischen einem Programmaufruf mit ausreichend vielen und zu wenigen Argumenten unterscheiden und die Schleife nur ber maximal [Link] Elemente laufen lassen. Ausnahmebehandlungen sind nur fr echte Ausnahmesituationen gedacht, nicht fr Situationen, die auch leicht durch andere Sprachkonstrukte beherrschbar sind. Der zweite Exception-Handler im Beispiel trit dagegen keine Annahmen ber die Stelle, an der eine Ausnahme geworfen wird, und er ist auch nicht leicht durch andere Sprachkonstrukte ersetzbar. Genau fr solche Flle ist das Abfangen von Ausnahmen gedacht: Man reagiert angemessen auf die Ausnahme, indem man entsprechende Informationen sammelt und zur Verfgung stellt. Gleichzeitig vermeidet man einen sofortigen Programmabbruch und fhrt das Programm (mglicherweise eingeschrnkt) fort, soweit die Ausnahme das erlaubt. Daten, mit denen man weiterrechnet, drfen im abgebrochenen Programmzweig nicht zerstrt worden sein. 5.5.2 Umgang mit Ausnahmefllen Bisher haben wir hauptschlich Ausnahmen betrachtet, die vom System geworfen werden um auf Fehler hinzuweisen. Es gibt aber auch Logikfehler, die einfach nur zu einem unerwarteten Programmverhalten fhren, aber zu keinem vom System erkannten Fehler. Wenn wir im laufenden Programm
355
5 Qualittssicherung
eine fehlerhafte Situation erkennen, die eigentlich nicht auftreten darf, knnen wir genauso wie das System eine Ausnahme werfen. Fr diesen Zweck gibt es die throw-Anweisung. Beispielsweise erzeugt throw new Exception("Ursache fr Ausnahme"); eine neue Instanz von Exception und wirft diese Instanz als Ausnahme. Wie bei vom System geworfenen Ausnahmen wird die Ausfhrung daraufhin unterbrochen und nach einem passenden Exception-Handler gesucht. Durch eine throw-Anweisung geworfene Ausnahmen werden genauso wie vom System geworfene abgefangen oder fhren zum Programmabbruch. Man kann nicht nur selbst Ausnahmen werfen, sondern auch eigene Typen von Ausnahmen einfhren. Jede direkt oder indirekt aus Throwable abgeleitete Klasse ist als Ausnahme verwendbar. blicherweise leiten wir neue Typen von Ausnahmen von Exception ab um klar zu machen, dass diese Ausnahmen abgefangen werden knnen. Genaugenommen gehen wir meist davon aus, dass eigene Arten von Ausnahmen tatschlich abgefangen werden. Abhngig von der Stellung des Typs der Ausnahme in der Klassenhierarchie wird das Abfangen sogar erzwungen: Methoden drfen nur solche Typen von Ausnahmen weiterleiten, die im Kopf der Methode angefhrt sind (siehe unten), sowie alle Untertypen von Error und RuntimeException (ein Untertyp von Exception). Die meisten vom System geworfenen Ausnahmen sind Instanzen einer dieser beiden Typen und knnen daher immer weitergeleitet werden. Auch Ausnahmen eigener Typen werden weitergeleitet, wenn sie als Unterklassen eines dieser beiden Klassen deniert wurden. Aber Ausnahmen, deren Typen, wie die meisten selbstdenierten Ausnahmetypen, direkt von Exception abgeleitet wurden, werden nicht automatisch weitergeleitet. Diese Ausnahmen mssen durch einen entsprechenden Exception-Handler abgefangen werden. Tun sie das nicht, meldet bereits der Java-Compiler einen Fehler, und die Klasse lsst sich nicht bersetzen. Listing 5.7 zeigt ein Beispiel fr die Denition eines eigenen Typs einer Ausnahme sowie die Deklaration einer von einer Methode weitergeleiteten Ausnahme. Die throws-Klausel bei der Methode test besagt, dass im Rumpf von test eine Ausnahme vom Typ TestException geworfen und an den Aufrufer weitergeleitet werden kann. Das bedeutet, dass jeder Aufrufer von test diese Ausnahme abfangen muss, wenn er sie nicht aufgrund einer eigenen throws-Klausel weiterleiten darf. Die Methode
356
5.5 Ausnahmebehandlung
Listing 5.7: Weiterleiten von Ausnahmen durch Methoden 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class TestException extends Exception { public TestException(String message) { super(message); } } public class ExceptionPropagationTest { public static void main(String[] args) { try { for (int i = 0; i < [Link]; i++) [Link](args[i]+": "+test(args[i])); } catch(TestException ex) { [Link]([Link]()); } } private static int test(String s) throws TestException { if ([Link]("end")) throw new TestException("Ende gut, alles gut"); else return [Link](); } }
main hat keine throws-Klausel fr TestException und daher muss es einen entsprechenden Exception-Handler geben. Im Allgemeinen kann die throws-Klausel einer Methode beliebig viele durch Komma voneinander getrennte Untertypen von Throwable enthalten. Aufrufer mssen darauf vorbereitet sein, dass jede solche Ausnahme weitergeleitet werden kann. Genau darin liegt der Vorteil: Aufrufer wissen, mit welchen Arten von Ausnahmen sie rechnen mssen. Natrlich kann auch jede Instanz eines Untertyps als Ausnahme weitergeleitet werden, wenn die throws-Klausel einen Obertyp davon enthlt. Nachteile von throws-Klauseln bestehen im hheren Programmieraufwand und in verminderter Flexibilitt. Wenn Ausnahmen ber mehrere Aufrufebenen hinweg weitergeleitet werden sollen, mssen viele Methoden mit throws-Klauseln ausgestattet werden. Oft entsteht die Notwendigkeit fr das Weiterleiten von Ausnahmen erst recht spt in der Programmentwicklung. Beim Hinzufgen einer Ausnahme mssen vielleicht viele Methoden gendert werden, obwohl die eigentliche nderung nur die beiden Stellen betrit, an denen die Ausnahme geworfen bzw. abgefangen
357
5 Qualittssicherung
wird. Aus diesem Grund mchte man throws-Klauseln vermeiden. Auch beim berschreiben von Methoden mssen throws-Klauseln bercksichtigt werden: Die Methode in der Unterklasse darf nicht mehr Ausnahmen weiterleiten als die berschriebene Methode der Oberklasse. In der throws-Klausel der Unterklasse drfen wir im Vergleich zu der in der Oberklasse folglich nur Typen weglassen, aber keine neuen hinzufgen. Der Grund dafr ist die Ersetzbarkeit: Wird eine Instanz eines Untertyps dort verwendet, wo eine Instanz eines Obertyps erwartet wird, sind nur die throws-Klauseln der Methoden des Obertyps bekannt, und nur diese Ausnahmen mssen abgefangen werden. Wenn die Methoden der Unterklasse andere Ausnahmen weiterleiten wrden als die der Oberklasse, knnten nicht alle Ausnahmen abgefangen werden. Die Einschrnkung verhindert, dass das passieren kann. Andererseits erhht die Einschrnkung den Wartungsaufwand: Wenn im Laufe der Programmentwicklung eine weitere Ausnahme dazukommt, mssen nicht nur die Methoden gendert werden, welche diese Ausnahme weiterleiten, sondern auch alle entsprechenden Methoden in den Oberklassen. Falls wir keinen Zugri auf die Oberklasse haben, ist es gar nicht mglich, die Ausnahme hinzuzufgen. Auf die Frage, ob die Vorteile der Verwendung von throwsKlauseln deren Nachteile berwiegen, gibt es keine klare Antwort. In manchen Fllen ist es sicher besser, eigene Klassen fr Ausnahmen von RuntimeException statt von Exception abzuleiten. Dagegen hat die Verwendung eigener Typen gegenber der Verwendung vordenierter Typen fr Ausnahmen klare Vorteile: Mit eigenen Ausnahmen haben wir volle Kontrolle ber alle Stellen im Programm, an denen die Ausnahmen geworfen werden knnen. In Abschnitt 5.5.1 haben wir gesehen, dass wir bei vom System geworfenen Ausnahmen nicht wissen, wo eine Ausnahme genau geworfen wird, und daher keine Annahmen darber treen drfen. Fr eigene Ausnahmen gilt das nicht. Das Abfangen der Ausnahmen wird dadurch wesentlich vereinfacht. Ausnahmen erlauben uns, vorzeitig aus Sprachkonstrukten auszusteigen. Im Beispiel in Listing 5.7 steigen wir aus einer for-Schleife aus, sobald wir auf den String "end" treen. Anders als bei der Verwendung von break mssen wir uns jedoch nicht an die lexikalische Struktur des Programms halten (wobei der Ausstiegspunkt textuell innerhalb des Schleifenrumpfs liegen muss), sondern knnen auch whrend der Ausfhrung einer im Schleifenrumpf aufgerufenen Methode aussteigen. Das erhht die Flexibilitt. Andererseits wird die Lesbarkeit vermindert, da die Stelle, an der eine Ausnahme abgefangen wird, weit von der Stelle des Werfens
358
5.5 Ausnahmebehandlung
entfernt sein kann. Man muss beide Stellen kennen um die Funktionsweise zu verstehen. Daher sollte man darauf verzichten, Ausnahmen nur zum Zwecke des vorzeitigen Ausstiegs aus Sprachkonstrukten einzusetzen. Methoden haben nur einen Ergebnistyp, und wir knnen kein Ergebnis eines anderen Typs zurckgeben. Oensichtlich erhht diese Einschrnkung die Lesbarkeit von Programmen ganz wesentlich. Ausnahmen erlauben uns jedoch, diese Einschrnkung zu umgehen. Beispielsweise gibt test in Listing 5.7 im Normalfall eine ganze Zahl zurck. Beim Werfen einer Ausnahme vom Typ TestException wird jedoch eine Zeichenkette an den Exception-Handler bergeben und damit quasi an den Aufrufer zurckgegeben. Im Detail geschieht das folgendermaen: Der Konstruktor von TestException gibt die Zeichenkette an den Konstruktor der Oberklasse Exception weiter, der die Zeichenkette in einer Objektvariablen speichert. Im Exception-Handler liest die von Exception geerbte Methode getMessage diese Objektvariable aus. Obwohl es manchmal verlockend ist, sollte man aus Grnden der Lesbarkeit darauf verzichten, Ausnahmen nur zum Zwecke der Rckgabe eines Ergebniswertes einzusetzen, der nicht dem Ergebnistyp der Methode entspricht. Der Einsatz von Ausnahmen ist in echten Ausnahmesituationen gerechtfertigt und ratsam, vor allem im Falle eines Fehlers. Ausnahmebehandlungen erlauben uns dabei, den Programmzustand nach einer unerwarteten Unterbrechung wieder in einen konsistenten Zustand zu bringen und die Programmausfhrung an einer geeigneten Stelle wieder fortzusetzen. In Abschnitt 5.5.3 werden wir ein gutes Beispiel dafr sehen. Ohne Ausnahmebehandlungen ist es sehr schwierig, mit solchen Ausnahmesituationen umzugehen. Vereinfacht kann man sagen, die Verwendung von Ausnahmen ist berall dort angebracht, wo Lsungen mit anderen Sprachkonstrukten nur unter viel hherem Aufwand mglich wren. Nicht jeder Exception-Handler kann die Auswirkungen der Ausnahme restlos beseitigen. Oft erledigt ein Exception-Handler nur einen Teil der Arbeit und wirft dann eine weitere Ausnahme, die von einem weiteren Exception-Handler an einer anderen Programmstelle abgefangen wird. Beispielsweise knnen wir im Exception-Handler die gerade abgefangene Ausnahme ex durch eine Anweisung throw ex; gleich nocheinmal werfen. Huger werfen wir im Exception-Handler jedoch eine andere Ausnahme, beispielsweise durch throw new Exception(ex);. Dabei bergeben wir an den Konstruktor von Exception (oder eine davon abgeleitete Klasse) die ursprngliche Ausnahme, die durch die Methode getCause() jederzeit auslesbar ist, sodass keine Information verloren geht. Eine inner-
359
5 Qualittssicherung
halb eines Exception-Handlers geworfene Ausnahme bricht die gesamte try-catch-Anweisung ab und kann nur durch eine weiter auen liegende solche Anweisung abgefangen werden. 5.5.3 Aufrumen Das Werfen einer Ausnahme unterbricht die Programmausfhrung an einer unerwarteten Stelle und hinterlsst Daten in inkonsistentem Zustand. Beim Abfangen der Ausnahme sollten wir aufrumen, also dafr sorgen, dass die Daten danach wieder konsistent sind. Allerdings knnen die Stellen des Werfens und Abfangens weit auseinander liegen, sodass der Exception-Handler keine bersicht ber den Schaden und keinen Zugri auf die inkonsistenten Daten hat. Auerdem ist beim Aufrumen eine Vielzahl mglicher Flle zu unterscheiden, die mit hoher Wahrscheinlichkeit zu Fehlern oder zumindest undurchschaubarem Programmcode fhrt. Um das Aufrumen zu erleichtern, untersttzt Java finally-Blcke, die auf try-Blcke und beliebig viele (vielleicht auch keine) ExceptionHandler folgen und in jedem Fall ausgefhrt werden, auch nach dem Werfen einer Ausnahme. In einem finally-Block steht der Programmcode zum Aufrumen von allem, was nach Ausfhrung des dazugehrenden try-Blocks aufgerumt gehrt. Im Normalfall wird zuerst ein try-Block vollstndig ausgefhrt, dann der finally-Block. Wird die Ausfhrung des try-Blocks aufgrund einer Ausnahme abgebrochen, so wird, falls vorhanden, ein passender Exception-Handler zwischen try- und finallyBlock ausgefhrt und danach der finally-Block. Steht fr diese Ausnahme kein geeigneter Exception-Handler zwischen try- und finallyBlock, wird der finally-Block ausgefhrt und danach die Ausnahme propagiert. Das passiert auch, wenn innerhalb eines Exception-Handlers eine weitere Ausnahme geworfen wird. Falls whrend der Ausfhrung des finally-Blocks eine Ausnahme geworfen wird, so wird nur diese weitergeleitet, und allenfalls vorher geworfene Ausnahmen werden vergessen. Listing 5.8 demonstriert den richtigen Umgang mit Ausnahmen einschlielich dem Aufrumen. Das Programm net eine Textdatei zum Lesen und eine andere zum Schreiben und kopiert den Inhalt der einen Datei Zeile fr Zeile in die andere, wobei die Zeilennummer vor jede Zeile gestellt wird. Am Ende mssen beide Dateien geschlossen sein. Beim nen, Lesen, Schreiben und Schlieen der Dateien knnen Ausnahmen vom Typ IOException geworfen werden, die abgefangen werden mssen. Da-
360
5.5 Ausnahmebehandlung
Listing 5.8: Aufrumen auch nach dem Werfen von Ausnahmen 1 import [Link].*; 2 public class Numbered { 3 public static void main(String[] args) { 4 if ([Link] != 2) { // Aufruffehler -> keine Ausnahme 5 [Link]("Usage: java Numbered <in> <out>"); 6 return; 7 } 8 try { 9 BufferedReader in = null; // fr finally sichtbar 10 BufferedWriter out = null; // Datei offen wenn != null 11 try { 12 String line; 13 in = new BufferedReader(new FileReader(args[0])); 14 out = new BufferedWriter(new FileWriter(args[1])); 15 for (int i=1; (line=[Link]()) != null; i++) { 16 [Link]([Link]("%6d: %s", i, line)); 17 [Link](); 18 } 19 } 20 finally { // immer ausgefhrt, auch bei Ausnahme 21 if (in != null) // falls Datei geffnet 22 [Link](); // dann schlieen 23 if (out != null) 24 [Link](); 25 } 26 } 27 catch(IOException ex) { 28 [Link]("I/O Error: " + [Link]()); 29 } 30 } 31 }
fr haben wir einen Exception-Handler. Eine Schwierigkeit besteht darin, dass wir oene Dateien auch dann schlieen mssen, wenn beim Lesen oder Schreiben eine Ausnahme geworfen wurde. Deswegen sind zwei tryBlcke ineinander geschachtelt. Der innere try-Block net die Dateien und kopiert die Daten, und der dazugehrige finally-Block schliet die Dateien. So ist sichergestellt, dass die Dateien auch bei Abbruch der beiden try-Blcke geschlossen werden. Der Exception-Handler am ueren try-Block fngt sowohl die im inneren try-Block als auch im finallyBlock geworfenen Ausnahmen des Typs IOException ab. Mit nur einem try-catch-finally-Konstrukt wre das nicht machbar.
361
5 Qualittssicherung
Variablen, die innerhalb eines Blockes deklariert werden, sind auerhalb des Blockes nicht sichtbar. Das gilt auch fr try-Blcke. Daher sind die Variablen in und out nicht im inneren try-Block deklariert, sondern im ueren. Sie mssen ja auch im finally-Block zugreifbar sein. Bei der Deklaration werden die beiden Variablen mit null initialisiert. Das ist notwendig, damit wir im finally-Block feststellen knnen, ob die zu schlieenden Dateien berhaupt genet waren, bevor mglicherweise eine Ausnahme geworfen wurde. Nicht genete Dateien knnen und brauchen wir natrlich auch nicht schlieen. Generell mssen wir beim Entwickeln von finally-Blcken immer in Betracht ziehen, dass try-Blcke nicht oder nur unvollstndig ausgefhrt wurden. Zu Beginn des Programms wird die Anzahl der Argumente in args ber eine einfache if-Anweisung abgefragt. Fr diesen Zweck vermeiden wir Ausnahmebehandlungen so gut es geht, da falsche Programmaufrufe so hug vorkommen, dass sie schon eher Normalflle als Ausnahmeflle sind. Auerdem ist es kaum mglich, entsprechende Ausnahmen von Ausnahmen aufgrund von Programmierfehlern zu unterscheiden. Allerdings sind nicht alle falschen Benutzereingaben durch diese einfache Abfrage abgedeckt. Insbesondere kann es passieren, dass die zum Lesen zu nende Datei gar nicht existiert. Dieser Fall fhrt zu einer IOException und einer gut verstndlichen Fehlermeldung. Oft entscheiden wir ganz pragmatisch, welche Situationen als Ausnahmeflle und welche als Normalflle zu betrachten sind. Der einfachere Ansatz ist meist der bessere. Das Schlieen geneter Dateien ist ein Paradebeispiel fr das Aufrumen. Java untersttzt eine ganze Reihe von Mglichkeiten fr den Umgang mit Dateien. Ein Grundprinzip bleibt berall gleich: Zuerst werden Dateien entweder zum Lesen oder Schreiben genet, dann wird daraus gelesen oder darin geschrieben, und schlielich mssen sie wieder geschlossen werden. Jede dieser Operationen kann eine Ausnahme vom Typ IOException werfen. Zwei Arten von Dateien werden unterschieden Byte-Streams, die nur rohe Daten (Bytes) enthalten, und CharacterStreams, deren Inhalt die Zeichen eines Textes sind. Diese Unterscheidung ist wichtig, weil Java unterschiedliche Zeichenstze untersttzt, deren Zeichen je nach Einstellungen unterschiedlich auf Bytes abgebildet werden. Bei Verwendung von Character-Streams erfolgt diese Abbildung automatisch. Zustzlich kann man gepuerte von ungepuerten Dateizugrien unterscheiden. Ungepuerte Zugrie verwenden zum Lesen und Schreiben direkt die entsprechenden Befehle des Betriebssystems, whrend gepuerte Zugrie aus einem bzw. in einen zwischengeschalteten Puer schreiben
362
5.6 Validierung
und lesen und erst bei Bedarf den Puer mit der Datei abgleichen. Der Puffer bewirkt eine Ezienzsteigerung auf Kosten der Aktualitt: Gelesene Daten entsprechen mglicherweise bereits einer lteren Dateiversion und geschriebene Daten werden erst verzgert in der Datei sichtbar. Die Variablen [Link] sowie [Link] und [Link] sind Instanzen von InputStream sowie PrintStream (ungepuerte Byte-Streams). Das Beispiel in Listing 5.8 verwendet gepuerte Character-Streams. Da der gesamte Inhalt der beiden Dateien auf einmal verarbeitet (gelesen bzw. geschrieben) wird, kommt es nicht auf die Aktualitt einzelner Textteile an. Instanzen der Klassen FileReader und FileWriter entsprechen ungepuerten Character-Streams. Instanzen von BufferedReader und BufferedWriter fgen Puer dazwischen. Zum Lesen verwenden wir die Methode readLine, die eine ganze Zeile auf einmal als Zeichenkette einliest, beim nchsten Aufruf die nchste Zeile und so weiter, bis die Datei keine weitere Zeile mehr enthlt und null zurckkommt. Die Datei selbst bleibt beim Lesen unverndert. Jede Instanz von FileReader und BufferedReader hnelt einem Iterator, wobei readLine der Iteratormethode next entspricht. Die Methoden write zum Schreiben einer Zeichenkette und newLine zum Beenden einer Zeile bewirken nderungen der Datei out. Geschrieben wird die durch die statische Methode format in String erzeugte Zeichenkette. In der Formatzeichenkette "%6d: %s" stehen die auf % folgenden Zeichen fr die Formatierung der Argumente, die zustzlich an format bergeben werden. Durch %6d wird die Zahl i als ganzzahliger Wert mit 6 Stellen ausgegeben. Es folgen ein Doppelpunkt und ein Leerzeichen, die auch so ausgegeben werden. Schlielich steht %s fr die Ausgabe von line als Zeichenkette.
5.6 Validierung
Der Begri Validierung (zu deutsch Bewertung ) ist berladen und kann vielerlei bedeuten. Wir wollen hier zwei ganz unterschiedliche Bedeutungen etwas nher betrachten, die beide im Zusammenhang mit der Programmkonstruktion stehen. 5.6.1 Validierung von Daten Daten, die in einem Programm verarbeitet werden, stammen aus unterschiedlichen Quellen beispielsweise aus einer Datenbank, einer Benutzereingabe, einer Messung oder dem Ergebnis frherer Berechnungen.
363
5 Qualittssicherung
Nicht alle Quellen sind gleich zuverlssig. Insbesondere sind Daten, die von Benutzern eingegeben wurden oder aus Messungen stammen, oft falsch. Wir wollen vermeiden, mit falschen Daten weiterzurechnen. Leider gibt es keine Mglichkeit festzustellen, ob die Daten richtig sind. Aber wir knnen berprfen, ob sie stimmen knnten. Wenn beispielsweise jemand als Geburtsdatum den 30. Februar 2345 angibt oder ein Sensor im Hochofen eine Temperatur von 543 C misst, sind wir recht sicher, dass die Daten falsch sind. Sie sind nicht plausibel. Unter Validierung verstehen wir in diesem Zusammenhang die Beurteilung, ob uns Daten als zuverlssig genug erscheinen um damit weiterzurechnen. Das nennt man auch Plausibilittsprfung. Je nach Situation knnen wir nicht plausible Daten verwerfen oder zurckweisen. Manchmal ist sogar ein Programmabbruch denkbar. Bereits in den ersten Beispielen (in der Klasse Zahlenraten in Abschnitt 1.1) waren Plausibilittsprfungen ntig: Benutzereingaben sollen Zahlen im Wertebereich zwischen 0 und 99 darstellen. Einerseits wird berprft, ob eine Eingabe berhaupt eine Zahl ist, andererseits ob die Zahl im gewnschten Wertebereich liegt. Diese beiden berprfungen mssen wir unterscheiden, weil unterschiedliche Manahmen zur Bereinigung der Situation ntig sind. Im einen Fall muss die falsche Eingabe entfernt werden, im anderen nicht. Solche Unterscheidungen sind hug ntig. Trotzdem sollte man die Anzahl der Unterscheidungen auf das notwendige Ma beschrnken. Je mehr Flle man unterscheiden muss, desto grer ist die Wahrscheinlichkeit, dass wir wichtige Flle bersehen und mit falschen Daten arbeiten. Einfache und klare Bedingungen dafr, wann die Daten als plausibel gelten, sind sehr erstrebenswert. Bei Plausibilittsprfungen darf man weder zu lockere noch zu restriktive Kriterien anlegen. Man kommt leicht in Versuchung, alles ausschlieen zu wollen, wofr man sich gerade keine sinnvolle Verwendung vorstellen kann. Das vermindert die Verwendbarkeit des Programms. Im Bewusstsein dieser Gefahr kommt man umgekehrt auch in Versuchung, fast alles zu tolerieren. Dies kann spter zu Problemen fhren, weil an zahlreichen Stellen Sonderflle behandelt werden mssen. Die richtige Wahl der Plausibilittskriterien erfordert Erfahrung und viel Fingerspitzengefhl. Man knnte Plausibilittsprfungen dort vornehmen, wo die in den berprfungen enthaltenen Bedingungen bentigt werden. Diese Strategie wrde leider dazu fhren, dass groe Teile des Programmcodes mit unzusammenhngenden, eingestreuten Plausibilittsprfungen berst wren. Das wre schlecht faktorisierter Code. Es ist eher sinnvoll, Plausibilittsprfungen an Schnittstellen vorzunehmen, wo die Daten in das System
364
5.6 Validierung
bernommen werden, fr Benutzereingaben beispielsweise an der Benutzerschnittstelle. An diesen Stellen sind Situationen, die aus fehlgeschlagenen berprfungen entstehen, meist auch am leichtesten zu bereinigen. Im Idealfall hat man ein eigenes Modul (z.B. eine Klasse oder ein Paket) fr die Plausibilittsprfungen. Damit kann man berprfungen vereinheitlichen, die sonst an verschiedenen Schnittstellen gemacht werden mssten, beispielsweise an der Benutzerschnittstelle und an der Schnittstelle zu einer Datenbank. Die zentrale Stelle lsst sich leichter konsistent halten. Implausible Daten mssen jedoch von den einzelnen Schnittstellen bereinigt werden, weil beispielsweise auf falsche Benutzereingaben anders reagiert werden muss als auf falsche Daten aus einer Datenbank. Plausibilittsprfungen sind klar von Zusicherungen zu unterscheiden. Whrend Zusicherungen in korrekt funktionierenden Programmen eigentlich niemals verletzt sein drften, muss man bei Plausibilittsprfungen stets mit einer Verletzung rechnen. Die berprfung von Zusicherungen ist daher im Normalfall ausgeschaltet, Plausibilittsprfungen drfen dagegen nicht ausgeschaltet werden. Daher eignen sich assert-Anweisungen nicht fr Plausibilittsprfungen. Ein weiterer Unterschied besteht darin, dass Zusicherungen ber das ganze Programm verstreut berall vorkommen, whrend Plausibilittsprfungen in gut faktorisierten Programmen nur an Schnittstellen zur Auenwelt (oder in einem zentralen Modul, das von diesen Schnittstellen verwendet wird) vorkommen. Generell gilt der Grundsatz: Never trust the user. Also alles, was direkt oder indirekt von Benutzern eines Systems kommt, sollte berprft werden. Allerdings gilt dieser Grundsatz nicht fr Entwickler. Man muss darauf vertrauen, dass alle an der Konstruktion des Programms beteiligten Personen die Regeln und Zusicherungen einhalten. Ohne Vertrauen kann kein gutes Programm entstehen. Anwender des Programms kennen die Regeln und Zusicherungen aber nicht. Plausibilittsprfungen sind daher kein Zeichen mangelnden Vertrauens, sondern eine Notwendigkeit. 5.6.2 Validierung von Programmen Bei der Verikation und beim Testen wird festgestellt, wie gut das Programm hinsichtlich der Anforderungsspezikation ist und in welchen Bereichen Verbesserungsbedarf besteht. Im Softwareengineering ist die Validierung die Bewertung des Programms hinsichtlich der tatschlichen Anforderungen. Die Anforderungen knnen sich verschoben haben, sodass sie von der Spezikation nur mehr unzureichend widerspiegelt werden. Die
365
5 Qualittssicherung
Verikation berprft, ob das Programm richtig entwickelt wird, whrend die Validierung berprft, ob das richtige Programm entwickelt wird. Durch verschiedene Manahmen versucht man sicherzustellen, dass das richtige Programm entwickelt wird: Gesprche mit knftigen Anwendern, um Unklarheiten und Fehler in der Analyse mglichst frh aufzudecken; Entwicklung von Benutzeroberchen-Prototypen (Programmen mit Benutzerschnittstellen hnlich denen der fertigen Produkte, aber ohne Funktionalitt dahinter) mit dem Zweck, knftigen Benutzern die Verwendung der Programme zu zeigen und Feedback einzuholen; in manchen Projekten Einbeziehung und Mitarbeit eines knftigen Benutzers (als Fachexperten) in die gesamte Entwicklung; Inkrementelle Entwicklungsmethoden, die ein frhes Feedback durch Anwender ermglichen; kurze Releasezyklen, die es erlauben, rasch auf genderte Anforderungen zu reagieren. Eine wichtige Frage ist die nach den Konsequenzen der Validierung. An obiger Liste ist sofort zu sehen, dass rasches Feedback von knftigen Anwendern im Mittelpunkt der Manahmen steht. Sobald wir am Feedback erkennen, dass die Entwicklung der Software in eine falsche Richtung luft, mssen wir sofort gegensteuern. Die Entwicklung wird damit von den Wnschen der Anwender getrieben. Ganz so einfach ist die Sache aber nicht. Viele Anwender wnschen sich natrlich immer bessere Programme, die alles knnen und gleichzeitig einfach zu bedienen sind. Alleine das ist schon ein Widerspruch in sich, da mit dem Funktionsumfang blicherweise auch die Komplexitt der Bedienung steigt. Viel wichtiger sind die Kosten. Auch wenn es mglich wre, sehr gute Programme zu entwickeln, stehen die Ressourcen dafr meist nicht zur Verfgung. Vor allem bei inkrementellen und agilen Softwareentwicklungsprozessen stehen wir stndig vor der Entscheidung, welcher Schritt als nchster gemacht werden soll. Wir knnen uns fr den Schritt entscheiden, der den Wnschen der Anwender am ehesten entgegenkommt, aber vielleicht die Kosten in die Hhe treibt, oder den, der die Kosten im Rahmen hlt, aber manche Anwenderwnsche unbercksichtigt lsst.
366
5.6 Validierung
Um den Kostenfaktor einzubeziehen, sollten wir statt von knftigen Anwendern eher von Auftraggebern (bzw. dessen Vertretern) sprechen. Von ihnen stammt in der Regel das Geld. Aber auch dabei mssen wir vorsichtig sein, da die Auftraggeber meist nicht selbst Anwender sind. Es mssen sowohl Auftraggeber als auch echte Anwender einbezogen werden. Im Endeekt treen zwar die Auftraggeber alle Entscheidungen, die sich auf die Kosten auswirken, aber ein vernnftiger Auftraggeber wird sich an gerechtfertigten Wnschen der Anwender orientieren. Gerade inkrementelle und agile Softwareentwicklungsprozesse funktionieren nur unter Einbeziehung aller Beteiligten gut. Andererseits kann sich die Einbeziehung zu vieler Personen in Entscheidungsprozesse auch negativ auswirken nach dem Motto: Viele Kche verderben den Brei. Es ist nicht leicht, ein ausgewogenes Ma zu nden. Man kann die Validierung als wirtschaftlichen Begri betrachten. Dabei stellt man, unter Abschtzung des Risikos, den Wert des Produkts ganz nchtern in Relation zu den Kosten der Realisierung. Das Produkt ist das zu konstruierende Programm. Sowohl der Wert des Programms als auch die Kosten der Realisierung sind nur grob abschtzbar. Genau diese Unwgbarkeiten lassen sich durch eine Abschtzung des Risikos beurteilen. Man wird ein Projekt nur machen, wenn es mit ausreichender Wahrscheinlichkeit einen Gewinn verspricht. Die Benutzbarkeit des Programms ist in dieser Betrachtung nur einer von vielen Faktoren, die den Wert des Programms bestimmen. Solche wirtschaftlichen berlegungen kann und soll man auch bei laufenden Projekten anstellen. Mitten im Projekt lassen sich der Wert des Produkts, die Kosten und das Risiko besser abschtzen als vor Beginn. Die Validierung beantwortet beispielsweise folgende Fragen: Zahlt es sich aus, das Programm weiterzuentwickeln, oder soll man das Projekt abbrechen und die Investition als verloren betrachten? Wohin soll die Weiterentwicklung gehen in Richtung einer Minimalversion oder in Richtung eines groen umfangreichen Systems? Wie kann man durch untersttzende Manahmen (Werbekampagnen, Ver- oder Zukauf von Lizenzen, Standardisierung, Nutzung von Synergien, etc.) den Wert des Programms erhhen und die Entwicklungskosten reduzieren? Manchmal bezeichnet man einfach nur den Abnahmetest als Validierung. Der Abnahmetest untersucht, ob das Programm im praktischen Be-
367
5 Qualittssicherung
trieb das hlt, was man sich anfangs davon versprochen hat, nicht nur, ob irgendwelche knstlichen Spezikationen erfllt sind. Insofern ist diese Bezeichnung gerechtfertigt. Die genaue Form des Abnahmetests ist meist vertraglich festgelegt. Sollten Fehler auftreten oder die Benutzbarkeit nicht gegeben sein, muss das Programm nachgebessert werden.
368
5.7.1 Kontrollfragen Was versteht man unter einer Spezikation? Wie genau soll eine Spezikation sein? Von welchen Faktoren hngt diese Genauigkeit ab? Auf welche Arten kann man ein Programm spezizieren? Wie geht man vor, wenn man Widersprche oder Ungenauigkeiten in einer Spezikation entdeckt? Was versteht man unter Design-by-Contract? Welche Bestandteile eines Softwarevertrags sind vom Client zu erfllen, welche vom Server? Woran erkennt man, ob eine Bedingung ein Vorbedingung, Nachbedingung oder Invariante darstellt? Wie mssen sich Vor- und Nachbedingungen bzw. Invarianten in Unter- und Obertypen zueinander verhalten? Warum stellen Invarianten einen Grund dafr dar, dass man keine public Variablen verwenden sollte? Inwiefern lassen sich Bedingungen statt als Zusicherungen auch in Form von Typen ausdrcken? Welche Rolle spielen Namen in Programmen (im Zusammenhang mit Zusicherungen)? Was versteht man unter der Namensgleichheit bzw. Strukturgleichheit von Typen? Was ist Duck-Typing, was das Gegenteil davon? Warum soll man Programme eher statisch als dynamisch verstehen? Wozu verwenden wir assert-Anweisungen? Sind assert-Anweisungen auch sinnvoll, wenn deren berprfung ausgeschaltet ist? Warum? Was ist eine Schleifeninvariante? Wozu verwenden wir sie?
369
5 Qualittssicherung
Wann muss eine Schleifeninvariante gelten? Wie gehen wir vor, um geeignete Schleifeninvarianten zu nden? Wann ist der richtige Zeitpunkt um Zusicherungen (insbesondere auch Schleifeninvarianten) in den Programmcode zu schreiben? Welche Formen von Zusicherungen ersetzen Schleifeninvarianten bei Verwendung von Rekursion statt Iteration? Wodurch unterscheidet sich die partielle von der vollstndigen Korrektheit eines Programms? Wann terminiert eine Schleife oder Rekursion? Was muss man zeigen, um die Termination formal zu beweisen? Gibt es praktische Unterschiede zwischen der Berechnung des zeitlichen Aufwands und dem Beweis der Termination? Wenn ja, welche? Spielen Terminationsbeweise auch in Programmen, die niemals terminieren sollen, eine Rolle? Warum? Inwiefern hngen Korrektheitsbeweise mit dem statischen Verstehen eines Programms zusammen? Was kann man mittels Model-Checking machen? Was versteht man unter Denial-of-Service-Attacken? Kann man die Fehlerfreiheit eines Programms sicherstellen? Wenn ja, wie? Ist es besser, beim Testen viele Fehler zu nden als wenige? Warum? Wie knnen beim Beseitigen eines Fehlers neue Fehler entstehen? Soll man auch Fehler korrigieren, bei denen die Wahrscheinlichkeit fr ein zuflliges Auftreten des Fehlers uerst gering ist? Warum? Aus welchen Grnden knnte jemand das Eindringen in ein System absichtlich ermglichen? Wodurch fhrt Testen zu einer Qualittsverbesserung? Was versteht man unter Code-Reviews?
370
Welche Ziele, Gemeinsamkeiten und Unterschiede gibt es zwischen Unit-, Integrations-, System- und Abnahmetests. Was versteht man in der Informatik unter einem Stresstest? Was charakterisiert White-, Grey- und Black-Box-Tests? Wofr stehen bei Laufzeitmessungen real-, user- und sys-Werte? Wovon hngen Laufzeiten ab, und warum unterscheiden sich gemessene Laufzeiten oft so stark voneinander? Wodurch unterscheiden sich Latenz und Bandbreite voneinander, und wann dominiert einer dieser Begrie den anderen? Wie kann man gemessene Laufzeiten auf grere Datenmengen hochrechnen? Was versteht man unter weichen und harten Echtzeitsystemen? Was ist ein Stack-Trace? Welche Informationen enthlt er? Wie kann man mittels assert-Anweisungen beim Auftreten von Fehlern etwas ber den Programmzustand erfahren? Was versteht man unter Debug-Output? Wozu dienen Logdateien? Was kann man mit einem Debugger machen? Warum ist das Finden von Fehlerursachen oft so schwierig? Wie gehen wir beim Sammeln von Fakten zum Aunden einer Fehlerursache vor? Warum lassen sich Ursachen fr Fehler, die vom Compiler gemeldet werden, hug einfacher nden als die fr andere Fehler? Was versteht man unter einer Ausnahmebehandlung? Wie und warum fngt man Ausnahmen ab? Welche Ausnahmen knnen in Java immer an den Aufrufer propagiert werden, welche nicht?
371
5 Qualittssicherung
Wie wird nach dem passenden Exception-Handler gesucht? Wofr sind Ausnahmebehandlungen gedacht, wozu sollten sie eher nicht verwendet werden? Wozu dienen die Schlsselwrter throw und throws in Java, und wie verwendet man die entsprechenden Sprachkonzepte? Welche Schwierigkeiten knnen durch throws-Klauseln entstehen? Was macht man mit finally-Blcken? In welchen Situationen werden sie ausgefhrt? Fr die berprfung welcher Art von Bedingungen eignen sich ifAnweisungen besser als Zusicherungen? Was unterscheidet Byte-Streams von Character-Streams in Java? Was unterscheidet gepuerte von ungepuerten Dateizugrien? Wozu dient die Methode format in der Klasse String? Was ist eine Plausibilittsprfung? Wohin soll man den Code fr Plausibilittsprfungen schreiben, wohin nicht? In welchen Situationen soll man Plausibilittsprfung vornehmen, in welchen nicht? Worin hneln Plausibilittsprfungen und Zusicherungen einander, wodurch unterscheiden sie sich? Was kann man mit der Validierung von Programmen bewirken?
372
6 Vorsicht: Fallen!
Die Programmkonstruktion ist ein Abenteuer. In zahlreichen Winkeln und Ecken lauern unbekannte Gefahren und drohen unsere Anstrengungen zunichte zu machen. Aber genau darin liegt der Reiz. Nur wer sich in die Abgrnde der Programmierung wagt und es schat, die unzhligen Fallen auf dem Weg zur Problemlsung geschickt zu umgehen, kann den Stolz auf diese Leistung verstehen. Dafr wird man sich immer wieder in neue, noch gefhrlichere Programmierabenteuer strzen. Wir mssen unsere Feinde kennen um sie zu besiegen. In diesem Kapitel wollen wir einige Fallen auf dem Weg zu einem hochwertigen Programm nher betrachten. Wir werden sehen, wie wir diese Fallen rechtzeitig erkennen und umgehen knnen.
373
6 Vorsicht: Fallen!
System dabei nicht behindern. Konkret verwendet Java einen GarbageCollector (auf deutsch etwa Mllsammler), der den von nicht mehr zugreifbaren Objekten belegten Speicherplatz automatisch wieder freigibt. Der Garbage-Collector arbeitet unsichtbar im Hintergrund. Wie in fast allen aktuellen Programmiersprachen unterscheiden wir in Java zwei Speicherbereiche, den Stack und den Heap. Wir haben schon gesehen, dass bei jedem Methodenaufruf ein neuer Eintrag auf den Stack gelegt wird. Der Stackeintrag enthlt neben Verwaltungsinformation (wie beispielsweise die Adresse, an die das Programm nach Beendigung der Methode zurckkehren soll) die Parameter sowie lokalen Variablen der Methode. Bei Beendigung der Methode wird der Stackeintrag wieder abgebaut. Der Heap enthlt alle durch new erzeugten Objekte, die auch nach Beendigung einer Methode erhalten bleiben. Heapeintrge knnen nur durch den Garbage-Collector abgebaut werden. Der Garbage-Collector sucht regelmig nach allen zugreifbaren Objekten und markiert diese, danach entfernt er alle nicht markierten und daher auch nicht zugreifbaren Objekte. Er beginnt mit der Suche im Stack, genauer bei den Parametern und lokalen Variablen in allen Stackeintrgen. Parameter und Variablen, die Objekte enthalten, verweisen auf die im Heap von den Objekten belegten Speicherbereiche. Von dort sucht der Garbage-Collector in allen Variablen der Objekte (Objektvariablen und statische Variablen) weiter, bis alle auf diese Weise aundbaren Objekte markiert sind. Garbage-Collection ist eine bewhrte Technik, die uns beim Programmieren viel Arbeit abnimmt. Auf eine Kleinigkeit mssen wir aber achten: Oft bleiben Objekte zugreifbar, obwohl sie tatschlich nicht mehr bentigt werden. Dadurch geht Speicher verloren. Wir knnen den GarbageCollector untersttzen, indem wir die Inhalte aller nicht mehr bentigten Variablen auf null setzen. Damit knnen die von den Objekten in den Variablen belegten Speicherzellen mglicherweise freigegeben werden, wenn diese Objekte nicht auch noch von anderen Variablen referenziert werden. Generell ist es eine gute Idee, an nicht mehr bentigte Variablen null zuzuweisen. Unabhngig davon, ob zustzlicher Speicher freigegeben wird, knnen wir beim Lesen des Programms sofort erkennen, dass ber die Variablen nicht mehr auf die vorher darin enthaltenen Objekte zugegrien wird. Das verbessert die Lesbarkeit. Man fragt sich, wie die Speicherverwaltung eine Falle sein kann, wenn Garbage-Collection bis auf das notwendige auf-null-Setzen von Variablen automatisch und meist sehr gut funktioniert. Das Problem ist, dass Garbage-Collection zwar meistens, aber eben nicht immer so gut funktio-
374
niert. Unter anderem treten folgende Schwierigkeiten auf: Garbage-Collection kostet etwas Zeit und ist gefhlt immer dann notwendig, wenn man es sich am wenigsten wnscht. Tatschlich braucht Garbage-Collection insgesamt oft weniger Zeit als die explizite Verwaltung von Speicher durch das Programm. Diese Tatsache ndert jedoch nichts am Gefhl, dass man beim Programmieren kaum Kontrolle darber hat, wann Garbage-Collection zu kleinen Verzgerungen bei den Antwortzeiten fhren kann. Das Freigeben nicht mehr bentigten Speichers ist nur ein kleiner Teil der Speicherverwaltung. Man kann Programme schon durch die Wahl der Algorithmen so auslegen, dass sie viel oder wenig Speicher verbrauchen. Wenn man eine Variante whlt, in welcher der bentigte Speicher den verfgbaren bersteigt, kann auch die beste automatische Speicherverwaltung nicht genug Speicher zur Verfgung stellen. In diesem Fall mssen wir nach einem besseren Algorithmus suchen. Es gibt Bereiche, in denen eine automatische Speicherverwaltung nicht sinnvoll ist, vor allem in sicherheitskritischen Systemen. Hier braucht man Garantien dafr, dass der bentigte Speicher in einer festgelegten Zeit verfgbar ist. Eine automatische Speicherverwaltung kann das nicht garantieren, wenn das Programm immer wieder neuen Speicher anfordert. Man muss also das ganze Programm so auslegen, dass der gesamte bentigte Speicher von Anfang an verfgbar ist. In diesem Bereich verwendet man eher Sprachen wie C, mit denen man auf niedriger Ebene mehr Kontrolle hat. Manchmal ist es aufwendig, nicht mehr bentigte Variablen auf null zu setzen, wenn man keinen direkten Zugri darauf hat. In diesen Fllen verzichtet man bewusst darauf und nimmt zugunsten krzerer Laufzeiten einen hheren Speicherverbrauch in Kauf. Auswirkungen auf sehr lange laufende Programme sind jedoch kaum abschtzbar. Ironischerweise fhrt gerade der Versuch, die Speicherverwaltung in Java besser zu kontrollieren, zu vielfltigen Problemen. Meist wollen wir die Speicherverwaltung kontrollieren, weil sich irgendwo ein Problem bei der automatischen Speicherverwaltung zeigt. Jedoch betreen Eingrie nur selten die Ursache des Problems und fhren deshalb oft nicht zum Ziel. Zumindest folgende Eingrismglichkeiten stehen zur Verfgung:
375
6 Vorsicht: Fallen!
Eine Ausnahme vom Typ StackOverflowError wird geworfen, wenn der Stack zu klein ist. Java-Interpreter lassen uns die Gre eines Stacks selbst bestimmen. Beispielsweise knnen wir ber die Option -Xssn1m beim Start des Java-HotSpot-Interpreters die Stackgre auf 1 Megabyte setzen. Allerdings ist die Ursache fr das Werfen dieser Ausnahme in den meisten Fllen eine Endlosrekursion wie im Beispiel in Listing 5.4. Ein grerer Stack lst das Problem nicht, sondern es dauert nur lnger, bis die Ausnahme geworfen wird. Um rascher eine Fehlermeldung zu sehen, wird die Stackgre normalerweise klein, aber fr die meisten Programme gro genug gewhlt. Es kann selten aber doch vorkommen, dass ein bestimmtes Programm mit der blichen Stackgre eine Ausnahme wirft, mit einem greren Stack aber nicht. Daher kann man sein Glck nach einem StackOverflowError mit einem greren Stack versuchen, auch wenn der Versuch kaum mit Erfolg gekrnt sein wird. Anders als der Stack wird der Heap automatisch vergrert, wenn es notwendig ist und der Computer genug Speicher hat. Trotzdem knnen wir eingreifen: Beim Start des Java HotSpot-Interpreters legen wir beispielsweise ber die Option -Xmsn6m eine Mindestgre von sechs Megabyte und ber -Xmxn66m eine Maximalgre von 66 Megabyte fest. Das ist etwa beim Testen des Programms sinnvoll, um die Lauhigkeit auf kleineren Computern zu berprfen oder die Auswirkungen der Garbage-Collection zu untersuchen. Es ist mglich, die Garbage-Collection in Java explizit aufzurufen: Runtime r = [Link](); [Link](); Wenn wir die Garbage-Collection an Stellen ausfhren lassen, an denen sie weniger strt, ist die Wahrscheinlichkeit kleiner, dass sie auch an anderen Stellen notwendig ist. Allerdings ist es schwer, Stellen im Programm zu nden, an denen die Garbage-Collection eine ausreichende Menge an Speicher freigeben kann. Auerdem passiert es leicht, dass wir gc viel huger aufrufen als notwendig. Tatschlich wird durch einem Aufruf von gc nicht unbedingt eine GarbageCollection durchgefhrt. Diese Methode weist den Garbage-Collector nur auf einen gnstigen Zeitpunkt fr eine Garbage-Collection hin. Der Garbage-Collector kann den Hinweis auch ignorieren.
376
Garbage-Collection ist kompliziert und von vielen Parametern abhngig. Moderne Java-Systeme lassen diese Parameter steuern, und ein Experte kann damit tatschlich Verbesserungen erzielen. Allerdings sollte man ohne spezielles Wissen ber Garbage-Collection und Details von Java-Interpretern die Finger davon lassen, da man viel eher eine Verschlechterung als eine Verbesserung erzielt. Jedes Java-Objekt hat die Methode finalize, die ausgefhrt wird, bevor Garbage-Collection den Speicherplatz fr das Objekt freigibt. Darin knnte man theoretisch irgendwelche Ressourcen freigeben. Leider hat das berschreiben von finalize durch eine nicht-leere Methode auch unerwnschte Auswirkungen. So kann der Speicherplatz nicht gleich freigegeben werden, weil vorher die Methode ausgefhrt werden muss, und der Speicherbedarf kann dadurch steigen. Daher und wegen der mangelnden Kontrollierbarkeit des Zeitpunktes der Ausfhrung wird von finalize kaum Gebrauch gemacht. Man kann die automatische Speicherverwaltung bewusst umgehen. Beispielsweise legt man gleich zu Beginn der Programmausfhrung fr jede Objektart eine Liste mit einer ausreichenden Anzahl dieser Objekte an. Wird ein Objekt bentigt, nimmt man das erste Objekt aus der entsprechenden Liste und initialisiert es neu. Wenn es nicht mehr bentigt wird, hngt man es wieder in die Liste ein. Durch solche Free-Lists ist es nicht ntig, nach Beendigung der Startphase neue Objekte zu erzeugen, und Garbage-Collection ist unntig. Allerdings muss man sich selbst um die Speicherverwaltung kmmern. Das ist ein groer Aufwand, und man macht dabei leicht Fehler. Daher sollte man Free-Lists in normalen Java-Programmen vermeiden. Fr fast alle Programmieraufgaben ist es am besten, die Speicherverwaltung dem System zu berlassen und nur untersttzend durch Zuweisung von null an nicht mehr zugegriene Variablen einzugreifen. 6.1.2 Dateien und Co Freier Platz auf Festplatten wird von einem Dateisystem automatisch verwaltet. hnlich wie bei der Speicherverwaltung hat man beim Programmieren mit der Verwaltung von Plattenplatz im Normalfall nichts zu tun. Man muss nur gelegentlich nicht mehr bentigte Dateien lschen.
377
6 Vorsicht: Fallen!
Dennoch bilden Dateien eine beschrnkte Ressource, die wir beim Programmieren selbst verwalten mssen. Mglicherweise wollen mehrere Programme gleichzeitig auf dieselbe Datei schreiben, oder ein Programm will auf eine Datei schreiben, whrend ein anderes von ihr liest. Wenn wir nicht aufpassen und auf eine korrekte Zugrisreihenfolge achten, ergibt sich ein Durcheinander, sodass die gelesenen Daten nichts mehr mit dem zu tun haben, was die anderen Programme zu schreiben glauben. Wie wir im Beispiel in Abschnitt 5.5.3 gesehen haben, werden alle Dateien zuerst genet, dann gelesen oder geschrieben und am Ende wieder geschlossen. Diese Vorgehensweise hat sich bewhrt um ein allzugroes Durcheinander zu vermeiden. Allerdings mssen wir sorgfltig darauf achten, dass auch tatschlich alle Dateien am Ende wieder geschlossen sind. In diesem Abschnitt geht es um Ein- und Ausgabe ganz allgemein, da Dateien nur einen Spezialfall der Ein- und Ausgabe darstellen. Auf ein Terminal wird beispielsweise genau so zugegrien wie auf eine Datei. Eine ganze Reihe von Klassen kmmert sich um die Ein- und Ausgabe: File: Instanzen dieser Klasse reprsentieren Dateien und erlauben beispielsweise die Feststellung der Dateiart und -gre sowie das Umbenennen. Schreib- und Lesezugrie werden jedoch nicht untersttzt. InputStream und OutputStream: Unter einem Stream versteht man einen Datenstrom, von dem immer wieder neue Daten gelesen bzw. auf den stets neue Daten geschrieben werden knnen. Es hngt von der Art des Streams ab, woher die Daten kommen bzw. wohin sie gehen. Instanzen von InputStream und OutputStream operieren auf rohen Bytes und interpretieren diese nicht. Diese beiden Klassen haben zahlreiche Unterklassen mit unterschiedlichen Eigenschaften. Beispiele sind FileInputStream und FileOutputStream fr die ungepuerte Ein- und Ausgabe sowie BufferedInputStream und BufferedOutputStream fr die gepuerte Ein- und Ausgabe. Die Klasse PrintStream erweitert OutputStream um Methoden zur Ausgabe von Daten unterschiedlicher Arten in einem Binrformat. Reader und Writer: Instanzen dieser beiden Klassen sind Streams von Zeichen, nicht von Bytes. Zu den zahlreichen Unterklassen gehren beispielsweise FileReader und FileWriter (ungepuert) sowie BufferedReader und BufferedWriter (gepuert). Als Brcken zwischen Zeichen- und Byte-Streams fungieren die Klassen
378
InputStreamReader und OutputStreamWriter. Die Klasse PrintWriter erweitert Writer um Methoden zur Umwandlung von Daten anderer Arten in fr Menschen lesbare Zeichenketten mit anschlieender Ausgabe. Scanner: Wie wir bereits in Abschnitt 1.1 gesehen haben, erlauben Instanzen dieser Klasse das Einlesen von Daten unterschiedlicher Arten. Das erwartete Datenformat entspricht dem, das beim Ausgeben ber PrintWriter erzeugt wird abgesehen von einfachen Zeichenketten nicht dem von PrintStream erzeugten Binrformat. System und Console: Die Klasse System stellt durch statische Variablen und Methoden eine Verbindung zum Betriebssystem her. ber die Variablen in (vom Typ InputStream) sowie out und err (vom Typ PrintStream), die der Standardein- und -ausgabe und der Fehlerkonsole entsprechen, werden hug einfache Ein- und Ausgaben realisiert. Aufrufe von [Link]() liefern die einzige Instanz von Console, welche die Mglichkeiten der Standardeinund -ausgabe erweitert, vor allem fr Passwortabfragen. Listing 6.1 erweitert das Beispiel aus Listing 5.8 um eine zweite Ausgabedatei. Abgesehen davon, dass die zu Beginn jeder Zeile ausgegebene Zeilennummer in einer Datei nur vier statt sechs Ziern umfassen kann, wird in beide Ausgabedateien dasselbe geschrieben. Ein weiterer kleiner Unterschied zu Listing 5.8 ist das zustzliche Argument im Konstruktor fr FileWriter: Der Wahrheitswert besagt, ob die geschriebenen Daten an eine bereits bestehende Datei angehngt werden sollen. Ist dieser Wert false (oder gar nicht angegeben), so wird der Inhalt einer bereits bestehende Datei desselben Namens beim nen gelscht. Mit diesem Beispiel demonstrieren wir, was passiert, wenn mehrfach auf dieselbe Datei geschrieben wird. Dazu rufen wir das Programm ber den Befehl java Numbered2 a b b auf, wobei a eine lngere Textdatei sein soll. An die Datei b wird der Inhalt von a zwei mal (mit Nummern vor jeder Zeile) angehngt, allerdings vermutlich nicht ganz so wie erwartet: Zuerst wird ein mehrere Zeilen umfassender Textblock entsprechend out1 angehngt, dann ein ebenso langer entsprechend out2, dann wieder einer entsprechend out1, und so weiter. An den Grenzen zwischen diesen Blcken steht nicht unbedingt ein Zeilenumbruch. Dieses Ergebnis erhalten wir, weil bei der gepuerten Ausgabe ein interner
379
6 Vorsicht: Fallen!
Listing 6.1: Klasse zum Testen der mehrfachen Verwendung einer Datei 1 import [Link].*; 2 public class Numbered2 { 3 public static void main(String[] args) { 4 if ([Link] != 3) { 5 [Link]("Usage: java Numbered in out1 out2"); 6 return; 7 } 8 try { 9 BufferedReader in = null; 10 BufferedWriter out1 = null; 11 BufferedWriter out2 = null; 12 try { 13 String line; 14 in = new BufferedReader(new FileReader(args[0])); 15 out = new BufferedWriter(new FileWriter(args[1], true)); 16 out = new BufferedWriter(new FileWriter(args[2], true)); 17 for (int i = 1; (line = [Link]()) != null; i++) { 18 [Link]([Link]("%6d: %s", i, line)); 19 [Link](); 20 [Link]([Link]("%4d: %s", i, line)); 21 [Link](); 22 } 23 } 24 finally { 25 if (in != null) 26 [Link](); 27 if (out1 != null) 28 [Link](); 29 if (out2 != null) 30 [Link](); 31 } 32 } 33 catch(IOException ex) { 34 [Link]("I/O Error: " + [Link]()); 35 } 36 } 37 }
Puer einer bestimmten Gre immer wieder befllt und dann in einem Schritt in die Datei geschrieben wird. Die Gre der Blcke entspricht der Puergre. Wenn wir wollen, dass der Inhalt der Datei b eine Zeile entsprechend out1 enthlt, dann eine Zeile entsprechend out2 und so weiter, so mssen wir dafr sorgen, dass der Puerinhalt nach jeder aus-
380
gegebenen Zeile in die Datei geschrieben wird. Das erreichen wir durch Aufruf von [Link]() unmittelbar nach [Link]() und [Link]() nach [Link](). Mit dieser nderung erhalten wir das erwartete Ergebnis. Auch eine ungepuerte statt einer gepuerten Ausgabe liefert dieses Ergebnis. Etwas anders sieht das Ergebnis aus, wenn wir das zweite Argument von FileWriter weglassen: Wir erhalten die Ausgabe entsprechend out2 gefolgt von einem Ende der Ausgabe von out1. Die Erklrung ist einfach: Beim nen wird ein mglicherweise schon vorhandener Inhalt gelscht. Unabhngig davon, ob nach jeder Zeile flush aufgerufen wird oder nicht, wird zuerst immer etwas entsprechend out1 geschrieben, dann entsprechend out2. Da nicht an die existierende Datei angehngt wird, sondern an das, was bereits ber denselben Stream geschrieben wurde, berschreibt die Ausgabe von out2 das, was vorher von out1 ausgegeben wurde. Das Ende der Ausgabe von out1 bleibt sichtbar, weil ber out1 insgesamt mehr Zeichen ausgegeben werden als ber out2. Bei Aufruf des Programms mit drei gleichen Argumenten, also etwa java Numbered2 a a a, erhalten wir Folgendes: Falls das zweite Argument von FileWriter weggelassen wird, erhalten wir eine leere Datei, weil beim nen der Inhalt gelscht wird, sodass danach auch nichts mehr gelesen werden kann. So wie in Listing 6.1 ergibt sich jedoch eine Endlosschleife: Die Datei wird stets um neue Zeilen erweitert, die dann wieder gelesen werden und neue Zeilen generieren. All diese Varianten von unerwartetem Verhalten bekommen wir, obwohl das Programm keinen erkennbaren Fehler enthlt. Es handelt sich nur um das normale Verhalten von Ein- und Ausgabe in ungewhnlichen Situationen. Um solches Verhalten zu vermeiden mssen wir die Ursachen dafr vermeiden. Beispielsweise knnen wir zu Beginn des Programms sicherstellen, dass alle drei Argumente verschieden sind. Folgende Fallen tauchen bei der Ein- und Ausgabe immer wieder auf: In unblichen Programmpfaden, beispielsweise nach dem Werfen einer Ausnahme, wird auf das Schlieen oder Hinausschreiben vergessen. Wir haben bereits in Abschnitt 5.5.3 gesehen, wie man solche Flle richtig handhabt. Am besten funktionieren finally-Blcke fr das Freigeben von Ressourcen in lokalen Variablen. Das Freigeben von Ressourcen ist unmglich, sobald das Objekt, das eine Objektvariable mit der Ressource enthlt, nicht mehr zugreifbar
381
6 Vorsicht: Fallen!
ist. Unzugreifbare Objekte werden durch Garbage-Collection entsorgt. Es sollte nicht passieren, dass unzugreifbare Objekte neben Speicher auch andere Ressourcen wie Dateien blockieren. Die Methode finalize (siehe Abschnitt 6.1.1) wurde eingefhrt, damit auch nicht mehr zugreifbare Objekte die von ihnen belegten Ressourcen freigeben knnen. Praktisch ist diese Methode aber nur beschrnkt verwendbar, weil nicht voraussehbar ist, ob und wann sie ausgefhrt wird. Abgesehen von einer generell vorsichtigen Programmierung gibt es keine Technik um diese Gefahr zu vermeiden. Am besten legt man wichtige Ressourcen nicht in Objektvariablen ab. Es gibt unterschiedliche Standards, die festlegen, wie Zeichen auf Bytes abgebildet werden. Man nennt sie Zeichen-Codierungen. In Java stellen die Klassen CharsetEncoder und CharsetDecoder die Funktionalitt fr die Umwandlung von Zeichen in Bytes und umgekehrt bereit. Instanzen von Charset sind Zeichenmengen zusammen mit den dazugehrenden Instanzen von CharsetEncoder und CharsetDecoder. Beim nen einiger Arten von Streams (etwa vom Typ InputStreamReader) kann man eine Instanz von Charset bzw. CharsetEncoder oder CharsetDecoder angeben. Gibt man keine Codierung an, so wird ein Default verwendet, der blicherweise vom Betriebssystem vorgegeben ist. Gelegentlich werden Daten mit einer anderen Codierung gelesen als sie geschrieben wurden. Derartige Fehler uern sich meist dadurch, dass Sonderzeichen und Umlaute falsch oder gar nicht dargestellt werden. Um solche Probleme gering zu halten, gibt man meist keine eigene Codierung an. Trotzdem knnen Konikte aufgrund unterschiedlicher Codierungen auftreten, weil Einstellungen am Betriebssystem falsch sind oder Dateien zwischen Rechnern mit unterschiedlichen Codierungen ausgetauscht werden. Wie wir gesehen haben, ergibt sich eine ganze Reihe von mglicherweise unerwnschten Eekten, wenn mehrfach auf dieselbe Datei geschrieben oder gleichzeitig geschrieben und gelesen wird. Solche Eekte knnen wir ausschlieen, wenn eine Datei nur einmal zum Schreiben genet werden darf. Eine einfache Mglichkeit dazu bieten Lock-Dateien, das sind Dateien, die beim nen einer anderen Datei angelegt und beim Schlieen wieder gelscht werden. Vor dem nen mssen wir berprfen, ob bereits eine Lock-Datei existiert. Auf manchen Systemen bietet die Klasse FileLock erweiterte Mg-
382
lichkeiten. Allerdings lsen Lock-Dateien und hnliches das Problem nur in einfachen Fllen, weil gleichzeitige Schreibzugrie manchmal durchaus erwnscht und notwendig sind, beispielsweise beim Schreiben von Logdateien siehe Abschnitt 5.4.1. Unerwnschte Eekte lassen sich dabei nur durch vorsichtige Programmierung und den Aufruf von flush() an den richtigen Stellen vermeiden. Durch PrintStream geschriebene Binrdaten knnen durch Instanzen von Scanner auer fr Zeichenketten nicht mehr eingelesen werden. Daher sollte man zum Schreiben eher PrintWriter verwenden. Insbesondere sollte man ber die hug verwendeten Variablen [Link] und [Link] nur Zeichenketten ausgeben, da diese Variablen vom Typ PrintStream sind. 6.1.3 Antwortzeiten Eine weitere wichtige beschrnkte Ressource ist Rechenzeit. Natrlich wollen wir eziente Programme schreiben, die mglichst wenig Rechenzeit brauchen und kurze Antwortzeiten haben. Unter der Antwortzeit verstehen wir die Zeit, die zwischen der Eingabe von Daten (beispielsweise Drcken der Enter-Taste) und dem Erhalt eines Ergebnisses vergeht. Kurze Antwortzeiten erhhen die Benutzerfreundlichkeit eines Programms. Erwartete Antwortzeiten hngen stark von der Art der Aufgabenstellung ab, angefangen von Sekundenbruchteilen bis zu Stunden oder sogar Tagen. Die Rechenleistung heutiger Computer ist reichlich bemessen, sodass kurze Antwortzeiten ohne allzugroen Aufwand realisierbar sein sollten. Die vorsichtige Formulierung deutet es schon an: Man wird immer wieder mit Programmen konfrontiert, von denen man aufgrund ihrer einfachen Aufgabe kurze Antwortzeiten erwartet, die tatschlich aber deutlich lnger brauchen. Grnde dafr knnen sehr vielfltig sein: Mglicherweise sind bentigte andere Ressourcen nicht sofort verfgbar. Wenn Daten aus einer Datenbank gelesen oder aus dem Internet geholt werden, stellt meist nicht die Rechenzeit, sondern die Wartezeit auf die Daten den entscheidenden Faktor fr die Verzgerung dar. Man sieht das bei Laufzeitmessungen daran, dass die realZeit deutlich grer als die user-Zeit ist. In diesem Fall bringt es kaum etwas, das Programm hinsichtlich der Rechenzeit zu optimieren. Die einzige sinnvolle Manahme zur Verkrzung der Antwortzeiten besteht darin, bentigte Daten gleichzeitig anzufordern, nicht
383
6 Vorsicht: Fallen!
ein Datenelement nach dem anderen. Oft lsst sich das aber nur ber nebenluge Programmierung bewerkstelligen, einem schwierigen und sehr fehleranflligen Bereich der Programmierung siehe Abschnitt 6.3. Nicht selten macht das Programm viel mehr als erwartet. Ein Grund dafr kann sein, dass die erwartete und tatschliche Komplexitt der Aufgabe weit auseinander liegen. Es kann aber auch sein, dass das Programm neben der eigentlichen Aufgabe noch ganz andere, versteckte Aufgaben erledigt. So gibt es Programme, die das Benutzerverhalten analysieren und Ergebnisse per Internet an zentrale Sammelstellen schicken. Manches Programm richtet durch die Erledigung versteckter Aufgaben Schaden an. Vielleicht gibt es geheime Informationen wie Passwrter oder Kreditkartendaten weiter, ermglicht anderen Personen Zugri auf das System oder verschickt verbotene Massenmails. Gelegentlich erkennt man Schadsoftware an unerwartet langen Antwortzeiten oder an unerklrlichen Netzwerkaktivitten. Die Prsentation der Ergebnisse ist oft sehr aufwendig gestaltet. Man gibt sich nur mehr selten mit einfachen Textausgaben auf einem Terminal zufrieden. Eine graphisch ansprechende Oberche mit individuell abgestimmten Bildern und vielleicht dazu passender Musik kann jedoch nicht nur den Entwicklungsaufwand, sondern auch die Antwortzeiten in die Hhe treiben, ohne den Nutzen zu erhhen. Bei der Konstruktion von Programmen liegen die Schwerpunkte oft auf kurzen Entwicklungszeiten und der einfachen Wartung, nicht auf der Laufzeitezienz. Das hat Vorteile. Aus diesem Grund werden bewusst einfachere Datenstrukturen und Algorithmen gewhlt sowie bereits fertige Programmteile eingebunden, auch wenn sie fr den Einsatzzweck nicht optimal sind. Solange die Antwortzeiten trotzdem noch tolerabel sind, ist dagegen nichts einzuwenden. Man tauscht kurze Antwortzeiten gegen kurze Entwicklungszeiten. Heute werden oft aufwendige Technologien eingesetzt, die eine einfachere Verwendung und Wartung der Software versprechen. Die Softwareindustrie bietet eine Vielzahl an Technologien an, die alle eine bestimmte Berechtigung haben. Beispiele sind Komponentenmodelle, Webanbindungen und Datenbankanbindungen. Wenn wir mehrere Technologien in unsere Programme einbinden, erhalten wir zwangslug viele bereinander liegende Schichten der Softwarearchitektur.
384
Obwohl der Einsatz bewhrter Technologien oft vorteilhaft ist, kann ein unberlegter Technologieeinsatz problematisch sein: Man bindet sich an manchmal sehr kurzlebige Technologien, durch viele bereinander liegende Schichten erhhen sich die Antwortzeiten, und bei nicht genau auf die Aufgabe passenden Technologien kommen die erhoten Vorteile nicht zum Tragen. Es entsteht die Gefahr der Abhngigkeit von bestimmten Technologien, die uns spezielles Expertenwissen und eine eziente Softwareentwicklung vorgaukeln. Manchmal werden Programme nicht fr kurze Antwortzeiten sondern beispielsweise geringen Speicherverbrauch optimiert. Laufzeit und Speicherverbrauch sind in der Regel gegeneinander austauschbare Ressourcen. Beispielsweise kann man mehrfach bentigte Werte immer wieder neu berechnen, oder nur einmal berechnen, zwischenspeichern und bei der nchsten Verwendung wieder laden. Welche Variante gnstiger ist, hngt von vielen Faktoren ab. Das Zwischenspeichern und Suchen nach gespeicherten Werten kann auch aufwendig sein, mglicherweise aufwendiger als Neuberechnungen. Man muss das Gesamtsystem im Auge haben, nicht nur einzelne Anwendungen. Beispielsweise kann man aktiv auf Ereignisse wie das Drcken von Tasten warten, indem man in einer Schleife wiederholt den Status der Tastatur abfragt; man spricht von Busy-Waiting. Alternativ dazu kann man passiv warten, indem man eine Methode aufruft, die untersttzt vom Betriebssystem die Ausfhrung des Programms unterbricht, bis eine Zeile eingegeben ist. Mglicherweise kann man durch Busy-Waiting um eine Winzigkeit rascher reagieren, da das Betriebssystem kein unterbrochenes Programm fortsetzen muss. Allerdings zahlt man einen hohen Preis, da das Programm stndig mit Abfragen beschftigt ist und mglicherweise anderen Programmen die Rechenzeit wegnimmt. So kann man die Antwortzeit eines Programms minimal verkleinern, indem man die Antwortzeiten anderer Programme mglicherweise stark erhht. Vom Gesamtsystem her betrachtet ist Busy-Waiting keine gute Technik. Wir haben bereits gesehen, dass die Auswahl geeigneter Datenstrukturen und Algorithmen einen groen Einuss auf Laufzeiten und Antwortzeiten haben kann. Sorgfalt bei der Auswahl ist ratsam. Andererseits haben wir auch gesehen, dass Laufzeitmessungen schwierig sind und leicht zu wertlosen Ergebnissen fhren. Diese Erkenntnis ist eine der Grundla-
385
6 Vorsicht: Fallen!
gen fr folgende Regeln bezglich der hndischen (nicht vom Compiler durchgefhrten) Programmoptimierung: Wer kein Experte dafr ist, sollte keine Optimierungen machen. Wer Experte dafr ist, sollte noch keine Optimierungen machen. Dass Nichtexperten die Finger von Optimierungen lassen sollten, ist klar: Die Gefahr von Fehlern durch Unkenntnis komplizierter Sonderflle ist zu gro. Aber auch Experten machen vieles falsch. Durch zu starke Konzentration auf die Laufzeit geht die einfache Wartbarkeit verloren. Optimierungen sind daher nur fr Programmteile sinnvoll, die sehr stabil sind und sich wahrscheinlich kaum mehr ndern werden. Wenn man zu frh optimiert, ist der Aufwand vergebens, weil jede spter notwendige Programmnderung die Optimierung wahrscheinlich wieder zunichte macht. Optimierungen sind extrem aufwendig. Niemand wird ein ganzes Programm optimieren, sondern nur jene kleinen Teile, die am meisten Zeit verschlingen, und das auch nur dann, wenn Antwortzeiten zu lang sind. Um ihre Aufgaben erfllen zu knnen, bentigen Programme Zugang zu Ressourcen. Man kann ihnen den Zugang zu diesen Ressourcen nicht verweigern, sondern hchstens auf das ntige Ma einschrnken. Betriebssysteme bieten Mglichkeiten zur Beschrnkung des Zugangs zu Ressourcen. Beispielsweise lsst sich die Zugreifbarkeit von Dateien steuern, eine Obergrenze fr den Speicherverbrauch einfhren, eine Prioritt bei der Vergabe von Rechenzeit an Programme festlegen, die Anzahl oener Netzwerkverbindungen begrenzen, und so weiter. Einerseits sollen Programme Zugang zu den bentigten Ressourcen bekommen, andererseits aber mglicher Schaden durch Schadsoftware begrenzt werden. Zugangsbeschrnkungen knnen leider nur die allerschlimmsten Auswirkungen schdlicher Software reduzieren, keinesfalls alle Schden vermeiden. Es gibt zahlreiche Anstze um Schden durch versteckte Aktivitten von Programmen gering zu halten. Manche Leute setzen nur Open-SourceSoftware ein, damit der Quellcode von Programmen oengelegt ist und versteckte, mglicherweise schdliche Programmteile direkt im Quellcode gefunden werden knnen. Das funktioniert leider nur begrenzt, weil niemand viele Millionen Codezeilen berblicken kann. Andere Leute bestehen auf zertizierte Software, bei der eine als seris angesehene Firma die Software untersucht und bestimmte Eigenschaften garantiert. Leider gibt es sehr unterschiedliche Arten von Zertikaten, zumeist solche, die nur den Ursprung der Software zertizieren. Der dazugehrige Vertrag schliet in
386
6.2 Grenzwerte primitiver Typ byte short int long existiert nicht Referenztyp Byte Short Integer Long BigInteger Anzahl Bits 8 16 32 64 nach Bedarf grte darstellbare Zahl 127 32.767 [Link] [Link].854.775.807 unbeschrnkt
der Regel jegliche Haftung fr Schden aus. Solche Zertikate sind relativ wertlos. Hug werden Virenscanner eingesetzt, die ein System regelmig auf das Vorhandensein von als schdlich bekannter Software untersuchen. Allerdings werden im Wesentlichen nur solche Programme gefunden, deren schdliche Wirkung schon bekannt ist. Ein wirklich sicherer Schutz wird durch keine der vielen mglichen Manahmen erreicht.
6.2 Grenzwerte
Nicht nur im Groen, sondern auch im Kleinen nden wir berall Grenzen und Schranken. So sind Zahlen der Typen int und Integer in Java auf 32 Bit begrenzt, und Zahlen kleiner als 231 = [Link] und grer als 231 1 = [Link] sind damit nicht darstellbar. Fliekommazahlen haben zwar einen viel greren Wertebereich, aber die Genauigkeit beim Rechnen mit diesen Zahlen ist begrenzt. Abseits von Zahlen haben auch Datenstrukturen begrenzte Kapazitten. Ein Array enthlt eine vorher bestimmte Anzahl an Elementen, und das Ende einer Liste wird durch null dargestellt. Wir mssen in Programmen Vorkehrungen treen um mit Situationen umzugehen, bei denen wir auf Grenzen stoen. 6.2.1 Umgang mit ganzen Zahlen Computer knnen sehr schnell und praktisch fehlerfrei rechnen. Ein gewhnlicher Computer unter dem Schreibtisch schat einige Milliarden primitiver Rechenoperationen pro Sekunde. Leider hat das auch seinen Preis: Der Computer wurde fr hohe Rechenleistung ausgelegt, nicht fr die einfache, bequeme und sichere Nutzung der Rechenleistung. Zur Erzielung dieser Leistung muss man einige Unannehmlichkeiten in Kauf nehmen.
387
6 Vorsicht: Fallen!
Listing 6.3: Programm zur Demonstration eines berlaufs 1 public class OverflowTest { 2 public static void main(String[] args) { 3 [Link]("50000 * 50000 = " + (50000 * 50000)); 4 } 5 } // Ausgabe: "50000 * 50000 = -1794967296" wegen berlauf!
Eine der grten Unannehmlichkeiten kommt von der begrenzten Anzahl an Bits fr die Zahlendarstellung in den primitiven ganzzahligen Typen byte, short, int und long. Abbildung 6.2 stellt diese Typen gegenber. Wenn die grte darstellbare Zahl n ist, dann ist die kleinste darstellbare Zahl (n + 1), da 0 bezglich der Darstellung zu den positiven Zahlen gehrt. Auch wenn Instanzen von long sehr groe Zahlen darstellen knnen, so kommt es in der Praxis dennoch vor, dass noch grere Zahlen gebraucht werden. Fr diesen Fall gibt es die Klasse BigInteger, deren Instanzen beliebig groe Zahlen (sofern der Computer gengend Speicher dafr hat) sein knnen. Wie jede Klasse ist BigInteger ein Referenztyp, und Rechenoperationen auf Instanzen von Referenztypen sind viel langsamer als solche auf Instanzen von primitiven Typen. Es gibt zu jedem primitiven Typ einen Referenztyp, weil manchmal Referenztypen notwendig sind beispielsweise fr Generizitt. Aus Ezienzgrnden verwenden wir trotzdem immer, wo dies mglich ist, primitive Typen. Beim Programmieren haben wir die Wahl zwischen ezienten Typen und unbeschrnkten Typen, beides zugleich geht aber nicht. In den allermeisten Fllen ist der Wertebereich von int oder zumindest long bei weitem ausreichend. Das Problem besteht eher darin, dass wir gelegentlich nicht wissen, ob der Wertebereich in Einzelfllen nicht doch zu klein sein knnte. Ein einfaches Programm in Listing 6.3 demonstriert, was passiert, wenn der Wertebereich zu klein wird: Das Ergebnis der Berechnung ist ohne Vorwarnung und ohne geworfene Ausnahme einfach nur falsch. Wir erwarten uns, dass die Multiplikation von 50.000 mit sich selbst [Link] ergibt. Dieses Ergebnis ist grer als die grte durch int darstellbare Zahl, und es kommt zu einem berlauf. Tatschlich stimmen die letzten 32 Bit in der Binrdarstellung der Zahlen [Link] und [Link] berein, aber wir wrden mindestens 33 Bit brauchen, um zwischen der negativen und positiven Zahl unterscheiden zu knnen. Bei
388
6.2 Grenzwerte
einem berlauf werden alle Bits, fr die kein Platz ist, einfach abgeschnitten. Dasselbe gilt fr einen Unterlauf, bei dem das Ergebnis zu klein ist um vollstndig dargestellt werden zu knnen. Die Zahl 50000 in unserem kleinen Programm ist vom Typ int, weil alle einfachen Zahlenliterale ohne besondere Kennzeichnung vom Typ int sind. Daher ist auch das Ergebnis der Berechnung vom Typ int. Den berlauf in Listing 6.3 knnen wir leicht vermeiden, indem wir zumindest eines der beiden Literale durch 50000L ersetzen. Durch Anhngen von L erhalten wir ein Literal vom Typ long, und die Multiplikation liefert ein Ergebnis vom Typ long, wenn mindestens ein Operand von diesem Typ ist. Tatschlich haben in der Praxis viele ber- und Unterlufe ihren Ursprung in der Verwendung von int wo long angebracht wre. Ein guter Teil davon lsst sich einfach durch Anhngen von L an Zahlenliterale vermeiden. Aber auch long kann ber- und Unterlufe nicht generell ausschlieen. Wir brauchen im Beispiel nur grere Zahlen zu verwenden, um einen berlauf von long zu erhalten. Wenn wir Instanzen von BigInteger verwenden, knnen keine berund Unterlufe passieren. Stattdessen werden bei Bedarf weitere Bits hinzugefgt. Entsprechende berprfungen und die aufwendige Verwaltung des Speichers bei nicht x vorgegebener Objektgre sind jedoch sehr aufwendig und kosten viel Zeit. Auerdem ist die Erzeugung von Instanzen von BigInteger umstndlich, weil wir dafr Konstruktoren bentigen, und Berechnungen sind komplizierter hinzuschreiben. Beim Rechnen mit ganzen Zahlen mssen wir auf eine Reihe mglicher Fallen achten: Zur Vermeidung von ber- und Unterlufen mssen wir den Wertebereich so abschtzen, dass es sicher zu keinen ber- und Unterschreitungen kommt. Die hug gebte Praxis nach dem Motto nehmen wir long, dann wird schon nichts passieren bieten keinen ausreichenden Schutz. Wenn der Wertebereich nicht abschtzbar ist, beispielsweise weil die mglichen Werte von Parametern nicht genau genug bekannt sind, dann mssen wir auf BigInteger zurckgreifen oder Werte an geeigneten Stellen dynamisch (durch ifAnweisungen) berprfen. Insbesondere mssen wir Daten aus anderen Quellen auf Plausibilitt prfen siehe Abschnitt 5.6.1. Grenabschtzungen von Zahlen sind nicht nur zur Vermeidung von ber- und Unterlufen notwendig. Beispielsweise nehmen wir, wenn
389
6 Vorsicht: Fallen!
wir Zahlen ausgeben, oft eine Obergrenze fr die Anzahl der Stellen dieser Zahlen an. So haben wir in der Klasse Numbered2 in Listing 6.1 nur vier bzw. sechs Stellen fr die Zeilennummer vorgesehen. Derartige Einschrnkungen kann BigInteger nicht umgehen. Fliekommazahlen haben einen greren Wertebereich als ganze Zahlen. Daher kommt immer wieder jemand auf die Idee, eine Fliekommazahl statt einer ganzen Zahl zu verwenden, wenn der Wertebereich nicht klar genug abschtzbar ist. Allerdings sind Fliekommazahlen kein Ersatz fr ganze Zahlen. Ganze Zahlen nimmt man, wenn man fehlerfrei, also ohne Rundungsfehler rechnen muss. Beim Rechnen mit Fliekommazahlen entstehen Rundungsfehler. Zur Berechnung von Nherungswerten sind Fliekommazahlen jedoch gut geeignet. Programmnderungen knnen leicht dazu fhren, dass Wertebereiche von Zahlen verndert werden. Die Abschtzungen der Wertebereiche, die man ursprnglich gemacht hat, sind damit hinfllig. Daran muss man bei der Programmnderung denken. Tatschlich haben viele falsch abgeschtzte Wertebereiche ihren Ursprung in Programmnderungen und im Einsatz bewhrter Software in neuen Bereichen. Ein spezielles Problem ist die Division durch Null, deren Ergebnis undeniert ist. In Java wird bei einer versuchten Division durch Null eine ArithmeticException geworfen. Unabhngig davon, ob man diese Ausnahme abfngt oder schon vorher prft, ob der Divisor gleich Null ist, muss man fr diesen Fall einen eigenen Programmzweig vorsehen. Einfacher ist es, wenn man aus einer statischen Analyse des Programms wei, dass der Divisor nicht gleich Null sein kann. In sehr vielen Fllen ist das mglich, weil Divisionen durch Null nicht sinnvoll sind und sich Beziehungen zwischen den Daten ganz natrlich so ergeben. Divisionen liefern im Allgemeinen keine ganzzahligen Ergebnisse. Daher wird der Divisionsoperator / auf ganzen Zahlen, der ein in Richtung Null gerundetes Ergebnis liefert, von einem Operator % zur Berechnung des Divisionsrests begleitet. Die Berechnung des Divisionsrests entspricht der Modulo-Berechnung, wenn beide Operanden nicht negativ sind. Eine mathematische Denition fr Modulo auf negativen Zahlen gibt es nicht. Wenn die Mglichkeit besteht, dass ein Operand negativ ist, mssen wir meist spezielle Vorkehrungen treen, die von Programm zu Programm verschieden sind.
390
6.2 Grenzwerte
Der Wertebereich ganzer Zahlen ist nicht vollkommen symmetrisch in positive und negative Zahlen geteilt. Daher kann auch die Negation durch - zu einem berlauf fhren: Die Negation der kleinsten darstellbaren Zahl liefert jeweils wieder die kleinste darstellbare Zahl, nicht die grte darstellbare Zahl. Also liefert - -2147483648 als Ergebnis wieder 2147483648, nicht 2147483648. Fr Gleichheitsvergleiche auf Instanzen von Referenztypen mssen wir die Methode equals verwenden, fr Vergleiche von Instanzen primitiver Typen gibt es dagegen nur ==. Der Grund dafr besteht darin, dass == die Objektidentitt vergleicht, nicht die Gleichheit. Gleichheit und Identitt bei primitiven Typen sind nicht unterscheidbar, sodass equals dafr weder ntig noch denierbar ist (da primitive Typen keine Untertypen von Object sind). Der Vergleich new Integer(125) == new Integer(125) liefert false, weil die zwei Objekte unabhngig voneinander erzeugt wurden. Es macht keinen Unterschied, ob sie denselben Wert haben. Dagegen liefert new Integer(125).equals(new Integer(125)) immer true. Noch diziler sind die Unterschiede zwischen equals und == bei Verwendung von Autoboxing siehe Abschnitt 4.5.1. Wenn wir zwei Variablen x und y vom Typ Integer haben, so werden durch die Anweisungen x=128; und y=128; implizit zwei neue Instanzen von Integer erzeugt und an die Variablen zugewiesen. Die Auswertung des Ausdrucks x==y liefert danach wie erwartet false. Wenn wir stattdessen aber die Zuweisungen x=127; und y=127; machen, ergibt x==y danach true. Die Erklrung besteht darin, dass Autoboxing fr Zahlen bis 127 keine neuen Objekte erzeugt, sondern bereits vorher bereitgestellte Instanzen von Integer verwendet. Bei zwei Verwendungen derselben Zahl wird also dasselbe Objekt eingesetzt. Fr grere Zahlen wird diese Technik nicht angewandt, weil sonst zu viele fertige Objekte bereitgestellt werden mssten. Dieses Beispiel zeigt, wie sehr wir auf die Unterscheidung zwischen equals und == achten mssen. Mit einfachem Ausprobieren ist es nicht getan. 6.2.2 Rundungsfehler Es ist bekannt, dass sich reelle Zahlen im Allgemeinen nicht mit endlich vielen Nachkommastellen genau darstellen lassen. Das gilt fr Dezimal-
391
6 Vorsicht: Fallen!
zahlen genauso wie fr Binrzahlen. Deshalb kann auch ein Computer reelle Zahlen nicht genau darstellen, und beim Rechnen mit reellen Zahlen mssen wir Rundungsfehler in Kauf nehmen. In der Programmierung unterscheiden wir hauptschlich zwei Darstellungsarten fr Zahlen mit Nachkommastellen: Fliekommazahlen: Je nach Typ bestehen diese Zahlen aus einer xen Anzahl an Ziern (binr oder dezimal). Das Komma innerhalb der Zahl ist in einem weiten Bereich verschiebbar. Nehmen wir an, eine Fliekommazahl besteht aus sechs Dezimalziern. Dann ist 1,23456 genauso darstellbar wie 123,456 und 12345,6, und wir knnen das Komma auch ber den Bereich der sechs Ziern hinausschieben, wodurch Nullen vorne oder hinten angehngt werden. So sind auch die Zahlen 0,000123456 und 123456000,0 durch denselben Typ und mit derselben Genauigkeit darstellbar. Zur Vereinfachung schreibt man diese Zahlen hug in der Exponentenschreibweise 1.23456e-4 und 1.23456e8 an,1 also die Zahl 1,23456 multipliziert mit 104 bzw. 108 , oder anders formuliert, das Komma um vier Stellen nach links bzw. acht Stellen nach rechts verschoben. Die Besonderheit von Fliekommazahlen besteht darin, dass die Rundungsfehler von der Gre der Zahl abhngen. In der Nhe von Null wird sehr genau gerechnet und auf viele Nachkommastellen gerundet, whrend die Rundungsfehler zunehmen, je weiter man sich von Null entfernt, egal ob in Richtung positiver oder negativer Zahlen. Diese Eigenschaft ist besonders sinnvoll, wenn man physikalische Gren, Wahrscheinlichkeitswerte oder hnliches ausdrckt. Festkommazahlen: Festkommazahlen haben dagegen eine x festgelegte Anzahl von Stellen nach dem Komma. Dadurch wird ber den gesamten Wertebereich hinweg mit demselben Rundungsfehler gerechnet. Manche, aber nicht alle Arten von Festkommazahlen sind dazu geeignet, Geldbetrge auszudrcken und mit Geldbetrgen zu rechnen. Fr Geldbetrge gibt es gesetzlich festgelegte Vorschriften, wie gerechnet und gerundet werden muss. Leider sind diese Vorschriften nicht berall auf der Welt ganz gleich. Fr Festkommazahlen kennt Java keine primitiven Typen. Wir mssen auf den Referenztyp BigDecimal zurckgreifen. Diese Klasse bietet
1
Im englischsprachigen Raum und in Programmen wird statt Komma , ein Punkt . verwendet.
392
6.2 Grenzwerte
zahlreiche Mglichkeiten zur Festlegung der Anzahl an Nachkommastellen sowie der Art und Weise, wie gerundet wird. Damit eignen sich Instanzen dieses Typs fr das Rechnen mit Geldbetrgen. Der Wertebereich ist wie bei BigInteger nicht beschrnkt. Das gesetzeskonforme Rechnen mit Geldbetrgen erfordert viel Spezialwissen. Es ist noch leicht nachvollziehbar, dass normalerweise auf ganze Cent (also zwei Nachkommastellen fr Euro-Betrge) genau gerechnet und Betrge ab 0,5 Cent aufgerundet, kleinere Betrge abgerundet werden mssen. Schwierigkeiten bereitet dagegen die Forderung, dass Summen immer genau zu stimmen haben. Wenn beispielsweise 1,00 Euro in drei gleiche Teile geteilt werden soll, so erhalten wir zwei Betrge von 0,33 Euro und einen von 0,34 Euro; drei Betrge von 0,33 Euro sind nicht erlaubt, da wir dabei nur auf eine Summe von 0,99 Euro kommen wrden. Hintergrnde solcher Schwierigkeiten sind juristischer Natur. Java untersttzt zwei primitive Typen von Fliekommazahlen, double und float. Normalerweise verwendet man double. Die viel ungenaueren Fliekommazahlen vom Typ float kommen nur in Spezialfllen zur Anwendung, in denen es auf eine sehr kompakte Darstellung oder die Ausnutzung von Spezialhardware (etwa in Graphik-Prozessoren) ankommt. Mit double wird auf ungefhr 15 Dezimalstellen genau gerechnet, mit float nur auf etwa 7 Stellen genau. Wertebereiche von 4,9e324 bis 1,7e+308 fr double und 1,4e45 bis 3,4e+38 fr float reichen meist aus. Die beiden Referenztypen Double und Float entsprechen hinsichtlich der Wertebereiche und Genauigkeiten ihren primitiven Gegenstcken. Ein Referenztyp fr Fliekommazahlen mit erweitertem Wertebereich und hherer Genauigkeit ist (anders als bei ganzen Zahlen) standardmig jedoch nicht vorgesehen. Dafr besteht kaum Bedarf. Auch das Rechnen mit Fliekommazahlen erfordert viel Spezialwissen. Das Hauptproblem stellt das Runden dar. Manchmal bekommen gerade die Stellen, die durch Runden ungenau sind, groe Bedeutung. Konkret passiert das bei der Subtraktion von zwei fast gleich groen Zahlen: Beispielsweise gilt 1,2345678 1,2345677 = 0,0000001, wobei die Dierenz jedoch um 8 Dezimalstellen weniger genau ist als die beiden anderen Zahlen. Bei Verwendung von float wrde das Bedeuten, dass wir alle relevanten Stellen verloren haben, und die Dierenz nur mehr Rundungsfehler widerspiegelt. Wenn wir mit dieser Zahl weiterrechnen, knnen wir uns kein Ergebnis erwarten, das auf irgendeine Weise sinnvoll wre. Dieses Problem nennt man Auslschung. hnlich wie bei einem berlauf bei ganzen Zahlen gibt es weder vom Compiler noch zur Laufzeit einen Hinweis auf
393
6 Vorsicht: Fallen!
die erfolgte Auslschung, sondern es sind einfach nur die Ergebnisse wenig sinnvoll. Am problematischsten ist natrlich der Fall, dass wir durch vollstndige Auslschung alle sinnvollen Stellen verlieren. Oft verlieren wir nicht alle, sondern nur einige Stellen. Auch dadurch wird das Endergebnis weniger genau. Die Anzahl der Stellen, die im Endergebnis noch sinnvoll sind, hngt hauptschlich vom gewhlten Algorithmus fr die Berechnung ab. Man spricht von einem gut konditionierten Algorithmus, wenn im Endergebnis viele Stellen sinnvoll sind und von einem schlecht konditionierten Algorithmus, wenn nur wenige Stellen sinnvoll sind. Die Verwendung von Additionen und Subtraktionen fhrt oft zu schlechterer Konditionierung als die von Multiplikationen und Divisionen. Manche Aufgaben beruhen aufgrund ihrer Natur auf bezglich Auslschung gefhrlichen Additionen und Subtraktionen. Solche schlecht konditionierten Aufgaben sind generell nur mit mehr oder weniger schlecht konditionierten Algorithmen lsbar. Gut konditionierte Aufgaben sind dagegen sowohl mit gut als auch schlecht konditionierten Algorithmen lsbar. Wir mssen also stets darauf achten, einen mglichst gut konditionierten Algorithmus zu nden. Anders als bei ganzen Zahlen ergeben berlufe von Fliekommazahlen nicht irgendeine falsche Fliekommazahl, sondern den speziellen Wert Innity, der auch als Konstante POSITIVE_INFINITY in Double deniert ist. Ebenso ergibt die Division einer positiven Zahl durch 0.0 Innity. Ein Unterlauf ergibt wie die Division einer negativen Zahl durch 0.0 den speziellen Wert Innity bzw. Double.NEGATIVE_INFINITY. Mit diesen speziellen Werten kann man auch rechnen. So ergibt die Division von Innity durch -2.0 den Wert Innity. Manche derartige Berechnungen ergeben jedoch keinen Sinn. Beispielsweise ist vllig unklar, welchen Wert die Division von Innity durch Innity ergeben soll. In solchen Fllen bekommen wir den speziellen Wert NaN (Not a Number) bzw. [Link]. Alle Operation ergeben Innity, Innity oder NaN wenn ein Operand Innity oder Innity ist, und alle Operationen ergeben NaN wenn ein Operand NaN ist. Ein weiterer Unterschied zu ganzen Zahlen besteht darin, dass zwischen 0.0 und -0.0 unterschieden wird. Diese beiden Zahlen stehen nicht fr genau Null, sondern fr beliebige positive bzw. negative Zahlen, fr deren Darstellung wir zu wenig Bits haben, genauso wie Innity fr beliebige Zahlen grer der grten darstellbaren Zahl steht. Folgende Fallen lauern im Bereich der Fliekommazahlen: Wenn uns nicht bewusst ist, wie viel Genauigkeit wir durch einen schlecht konditionierten Algorithmus verlieren, nehmen wir meist ei-
394
6.2 Grenzwerte
ne viel zu groe Genauigkeit der Ergebnisse an. Die Verwendung von double statt float kann die Genauigkeit zwar erhhen, aber nur um wenige Stellen. Ein Algorithmus, der bei Verwendung von float zur vollstndigen Auslschung fhrt, ist hug auch bei Verwendung von double nicht viel besser. Aufgrund von Rundungsfehlern ist es nicht sinnvoll, Fliekommazahlen mittels == zu vergleichen. Meist wollen wir Zahlen als gleich betrachten, auch wenn sie sich um einen kleinen Betrag unterscheiden. Statt x == y schreiben wir eher [Link](x - y) < eps, wobei die statische Methode abs aus der Klasse Math den Absolutwert berechnet und die Variable eps den maximal an dieser Stelle erwarteten Rundungsfehler enthlt. blicherweise bezeichnet man Rundungsfehler durch den griechischen Buchstaben (Epsilon). Der Wert von eps sollte sowohl von den erwarteten Wertebereichen als auch den Anzahlen der zuverlssigen Stellen von x und y abhngen. Eine spezielle Form der Rundung ist die Absorption: Addiert man beispielsweise 1e10 mit 1e10, so ist das Ergebnis wieder 1e10, weil die kleine Zahl vllig in den Rundungsfehlern untergeht. Normalerweise ist eine Absorption kein groes Problem. Werden jedoch viele kleine Zahlen zu einer groen addiert, kann sich die Rundung auswirken, da die Summe der kleinen Zahlen im Vergleich zur groen nicht vernachlssigbar ist. Es kommt etwas anderes heraus, wenn man die Summe der kleinen Zahlen zur groen Zahl addiert als wenn man jede kleine Zahl einzeln zur groen addiert. bliche Rechengesetze wie Assoziativ- und Distributivgesetz gelten nicht. So sinnvoll die Verwendung spezieller Werte wie Innity und NaN durch Experten in der Praxis ist, so schwierig ist der Umgang mit ihnen durch Nichtexperten. Man muss stets damit rechnen, dass ein Ergebnis ein spezieller Wert ist. Besonderheiten treten bei Vergleichen mittels == auf: Der Vergleich von 0.0 und -0.0 ergibt immer true (obwohl zwischen diesen beiden Werten unterschieden wird), whrend der Vergleich von NaN mit NaN immer false ergibt. Um festzustellen, ob eine Zahl x den Wert NaN hat, verwenden wir die spezielle Methode [Link](x) (oder schlicht x==x, weil dieser Vergleich auer fr NaN immer true ergibt). Fr die Methode equals in Double gelten diese Besonderheiten nicht; das erleichtert beispielsweise das Einfgen von Fliekommazahlen in generische
395
6 Vorsicht: Fallen!
Hashtabellen. Bei genauerer Betrachtung sind diese Besonderheiten sinnvoll. Aber wenn man wenig Erfahrung im Umgang mit Fliekommazahlen hat, erscheinen einem die Besonderheiten als Fallen. 6.2.3 Null Jede Variable, deren Typ ein Referenztyp ist, kann statt einer Instanz dieses Typs null enthalten. Wie wir in Kapitel 4 gesehen haben, brauchen wir null (oder etwas Vergleichbares) als Basis rekursiver Datenstrukturen. Normalerweise markiert null das Ende einer rekursiven Datenstruktur, beispielsweise das Ende einer Liste, einen nicht vorhandenen Zweig eines Baumes oder einen leeren Eintrag in einer Hashtabelle. Oft verwenden wir null auch unabhngig von rekursiven Datenstrukturen. Beispielsweise kann null in einem formalen Parameter bedeuten, dass statt eines bergebenen Arguments ein von der Methode bestimmter Defaultwert2 verwendet werden soll. Leider beherbergt der Umgang mit null einige Unannehmlichkeiten und Fallen. Insbesondere mssen wir Fallunterscheidungen machen, bevor wir eine Nachricht an den Inhalt einer Variablen schicken, der mglicherweise null ist. Das heit, statt einer einfachen Anweisung [Link](); mssen wir eine viel kompliziertere bedingte Anweisung verwenden: if (x != null) { [Link](); } else { ... mache etwas anderes ... } Wir haben nicht nur einen erhhten Schreibaufwand, sondern wir mssen uns auch berlegen, was im else-Zweig zu tun ist. Aus der falschen Annahme, dass x nicht null sein kann, oder schlicht aus Unachtsamkeit verwenden wir gar nicht so selten eine einfache Anweisung, obwohl eine bedingte Anweisung notwendig wre. Zur Laufzeit wird in solchen Fllen eine NullPointerException geworfen. Nicht umsonst zhlen solche Ausnahmen zu den hugsten Ursachen fr einen unvorhergesehenen, fehlerhaften Programmabbruch. Gelegentlich ndet man Programmcode, in dem zur Vermeidung bedingter Anweisungen Nachrichten ganz bewusst an Inhalte von Variablen
2
Unter einem Defaultwert oder kurz Default versteht man ganz allgemein einen Wert, der dann zu verwenden ist, wenn kein anderer Wert explizit vorgegeben ist.
396
6.2 Grenzwerte
geschickt werden, obwohl die Variablen null enthalten knnen. Entsprechende Ausnahmen werden in einem eigens dafr vorgesehenen catchBlock abgefangen. Von einer solchen Vorgehensweise ist aber dringend abzuraten: Einerseits wei man meist nicht sicher, sondern vermutet nur, wo genau die Ausnahme geworfen wurde. Solche Annahmen knnen falsch sein, sodass zur Ausnahmebehandlung gnzlich ungeeigneter Code ausgefhrt wird. Andererseits ist jede Ausnahmebehandlung aus semantischer Sicht hochgradig komplex und damit fehleranfllig. Gerade im Zusammenhang mit dem Umgang mit null knnen wir uns diese unntige Komplexitt leicht ersparen. Es ist sehr wichtig, dass man durch Zusicherungen (vor allem an Schnittstellen, also in Vor- und Nachbedingungen) klar macht, welche Variablen und formale Parameter null enthalten und welche Methoden null zurckgeben knnen und welche nicht. Wenn null erlaubt ist, muss natrlich auch geklrt werden, wofr null steht. Nur so entsteht ein Vertrag zwischen einem Client und einem Server, bei dem beide Vertragspartner wissen, was der jeweils andere von ihnen erwartet. Java selbst bietet dafr derzeit leider kaum brauchbare Untersttzung. Es gibt schon seit einiger Zeit berlegungen, das Typsystem von Java dahingehend zu erweitern, dass man im Typ ausdrcken kann, ob null erlaubt ist oder nicht. Technische Mglichkeiten zur statischen berprfung dieser Eigenschaft durch den Compiler wren vorhanden. Jedoch wrde die erweiterte Typinformation das Problem nur zum Teil lsen. Man wsste zwar, wo null erlaubt ist, aber nicht, wofr null steht. Auch knnte man nicht mit Bedingungen umgehen, die klarer beschreiben, in welchen Situationen null erlaubt ist und in welchen nicht. Betrachten wir als Beispiel einen Iterator, etwa ListIter<A> aus Listing 4.30. Wenn ein Aufruf von next() kein weiteres Element im Aggregat zurckgeben kann weil keines mehr vorhanden ist, wird null zurckgegeben. Allerdings wird null auch dann zurckgegeben, wenn das Aggregat null als Element enthlt. Aus dem Ergebnis null von next() drfen wir nicht schlieen, dass der Iterator keine weiteren Elemente zurckgibt, da null in zwei ganz unterschiedlichen Bedeutungen vorkommen kann. Zur Unterscheidung bentigen wir die Methode hasNext(). Wir wollen vermeiden, dass null in mehreren Bedeutungen vorkommt. Das gelingt aber nicht immer. In diesen Fllen mssen wir, wie im Iterator, andere Unterscheidungsmglichkeiten vorsehen. Am besten weisen wir auf diesen Umstand in den Zusicherungen ganz klar hin, damit Aufrufer keine falschen Annahmen treen.
397
6 Vorsicht: Fallen!
Listing 6.4: Beispiel zur Verwendung von null public interface Motor { double gCO2proKm(); } public class Benzinmotor implements Motor { private double literPro100km; // Verbrauch in l/100km public int double gCO2proKm() { return literPro100km * 23.7; // Umrechnung in Gramm CO2/km } ... // Konstruktor, etc. } public class Fahrzeug1 { private Motor motor; // null fr Fahrrad, etc. public double gCO2proKm() { if (motor != null) { // Bedingte Anweisung ! return motor.gCO2proKm(): } else { return 0.0; } } ... }
Der Bedarf an einer Verwendung von null ergibt sich sehr oft von alleine, beispielsweise weil wir rekursive Datenstrukturen aufbauen mssen oder in manchen Situationen kein passendes Objekt haben um es als Argument zu bergeben oder als Ergebnis zurckzugeben. Ohne dringende Notwendigkeit sollten wir die Verwendung von null jedoch vermeiden, da damit immer ein hherer Programmieraufwand und eine grere Fehleranflligkeit verbunden ist. Listing 6.4 zeigt einen typischen Fall einer vermeidbaren Verwendung von null. Instanzen der Klasse Fahrzeug1 stellen Fahrzeuge aller Art dar, deren Kraftstoverbrauch vom Motor bestimmt wird. Fr Fahrzeuge ohne Motor, z.B. Fahrrder, enthlt die Variable motor den Wert null. Daher brauchen wir bedingte Anweisungen, wenn wir Nachrichten an motor schicken. Wenn wir an vielen Stellen auf motor zugreifen, ergibt sich durch den Sonderfall von Fahrzeugen ohne Motor eine deutlich hhere Komplexitt. Wie Listing 6.5 zeigt, knnen wir die Komplexitt reduzieren indem wir den Sonderfall vermeiden. Wir brauchen nur eine zustzliche Klasse KeinMotor, deren Instanzen in Fahrzeugen ohne Motor
398
6.2 Grenzwerte
Listing 6.5: Beispiel zur Vermeidung von null (erweitert Listing 6.4) public class KeinMotor implements Motor { public int double gCO2proKm() { // motorlose Fahrzeuge haben return 0.0; // keinen Verbrauch } ... // Konstruktor, etc. } public class Fahrzeug2 { private Motor motor; public double gCO2proKm() { return motor.gCO2proKm(); } ... }
verwendet werden, sodass es keinen Grund mehr fr null in motor gibt. Die Unterscheidung zwischen Motorarten (Benzinmotor, Dieselmotor oder auch kein Motor) erfolgt durch dynamisches Binden, nicht durch bedingte Anweisungen. Da wir im Beispiel ohnehin dynamisches Binden brauchen, ist der Ansatz von Fahrzeug2 in allen Belangen dem von Fahrzeug1 berlegen Schreibaufwand, Laufzeitezienz, Wartbarkeit, etc. Eine solche Technik knnen wir immer verwenden um null zu vermeiden. So knnten wir zwei Arten von Listenknoten unterscheiden, einen leeren (der als Ersatz fr null dient) und einen nichtleeren, und ein gemeinsames Interface dafr einfhren. Damit bruchten wir null nicht zur Darstellung des Endes einer Liste und ganz allgemein nicht als Basis fr rekursive Datenstrukturen. Allerdings wre eine solche Listenimplementierung der blichen Implementierung nicht (oder zumindest nicht eindeutig) berlegen. Anders als in obigem Beispiel brauchen wir in der blichen Listenimplementierung kein dynamisches Binden zur Unterscheidung zwischen unterschiedlichen Arten von Listenknoten und nur eine Listenknotenklasse statt zwei Klassen und einem Interface. Diese Technik wrde also ebenso einen Zusatzaufwand nach sich ziehen wie bedingte Anweisungen fr null. Daher wird hug null verwendet. Der wichtigste Grund dafr ist jedoch schlicht und einfach die Tatsache, dass die Mehrzahl der Programmierer es so gewohnt ist. Es wrde auch ohne null, dafr mit selbstdenierten Klassen als Ersatz fr null ganz gut gehen.
399
6 Vorsicht: Fallen!
6.2.4 O-by-One-Fehler und Puerberlufe Viele Informatiker(innen) leiden unter einer Krankheit: Wenn man mit ihnen spricht, kommen sie kaum auf das Wesentliche, sondern verrennen sich stndig in Verallgemeinerungen und Nebenschlichkeiten. Glcklicherweise liegt die Ursache dafr nicht darin, dass Informatiker(innen) das Wesentliche nicht erkennen. Ganz im Gegenteil. Sie sind es gewohnt, in sehr komplexen Zusammenhngen und auf hohem Abstraktionsniveau zu denken. Sie sind es jedoch auch gewohnt, sich neben dem Wesentlichen auf Sonderflle, Randbedingungen und Grenzen zu konzentrieren. Nur auf diese Weise knnen qualitativ hochwertige Programme entstehen. Der wesentliche Kern ist im Vergleich zum gesamten Algorithmus oder Programm meist nur recht klein. Ein weitaus grerer Teil dient der Initialisierung, der Behandlung von Sonderfllen und hnlichem. Fehler passieren eher bei Initialisierungen, in Abbruchbedingungen und bei der Behandlung von Sonderfllen als in den meist besser durchdachten wesentlichen Teilen. Diese gefhrlichen Stellen kann man durchwegs als Grenzen betrachten als Grenzen zwischen der normalen Ausfhrung und dessen Beginn, dessen Ende, oder einem alternativen Pfad dazu, dem in manchen Fllen gefolgt werden muss. Die Erfahrung zeigt, dass gerade an diesen Grenzen sehr hug sogenannte O-by-One-Fehler auftreten, also Fehler, in denen beispielsweise eine Schleife mit einem um Eins zu kleinen oder zu groen Index beginnt oder um Eins zu frh oder zu spt abbricht. Man kann sich viele mehr oder weniger triviale Grnde fr solche Fehler vorstellen beispielsweise dass man manchmal bei Null und manchmal bei Eins zu zhlen beginnt, dass man einen kleiner- mit einem kleinergleich-Operator verwechselt, und so weiter. Auch wenn man wei, dass der Wertebereich von 0 bis 100 insgesamt 101 Zahlen umfasst, ist man durch die Magie runder Zahlen immer wieder verwirrt. Ein nicht ganz so trivialer Grund drfte ebenso eine wichtige Rolle spielen: Whrend man zur Lsung des Kerns einer Aufgabe sehr abstrakt denkt, etwa an Beziehungen zwischen Variablen ohne bestimmte Werte, muss man an den Grenzen an ganz konkrete Variablenwerte denken. Der bergang zwischen abstraktem und konkretem Denken (und umgekehrt) wirkt als Bruchlinie, an der man leicht die Zusammenhnge verliert. Gelegentlich gelingt der bergang nicht ganz. Man verharrt in abstraktem Denken, und als konkrete Werte an den Grenzen nimmt man Werte, die man aus hnlichen (aber nicht gleichen) Situationen noch in Erinnerung hat. Die knnen falsch sein, liegen aber oft nahe bei den richtigen Werten.
400
6.2 Grenzwerte
In Kapitel 5 haben wir schon viele ber Qualittssicherung erfahren. All das gilt auch und insbesondere fr Grenzen. Zur Vermeidung von Fehlern an Grenzen sollten wir folgendes Beachten: In Zusicherungen mssen wir vor allem Bedingungen festhalten, welche die Grenzen klar beschreiben. Beispielsweise haben wir in der Klasse UnbekannteZahl in Listing 1.2 festgelegt, dass die unbekannte Zahl zwischen 0 und grenze-1 liegt, wobei grenze>0 gilt. Das ist ein typischer Fall der Beschreibung von Grenzen. Den Normalfall brauchen wir kaum zu beschreiben, weil der ohnehin klar ist. Code-Reviews knnen Fehler an Grenzen recht erfolgreich aufzeigen. Wichtig ist jedoch, dass wir uns wirklich vergewissern, dass die Grenzen passen und den Code nicht nur oberchlich beriegen. Ordentlich durchgefhrte Code-Reviews sind sehr anstrengend. Man kann sich kaum lnger als etwa 20 Minuten ohne Unterbrechung so gut auf den Code konzentrieren, dass dabei Fehler auallen. Glcklicherweise fallen viele O-by-One-Fehler beim Testen gleich auf, aber leider nicht alle. Wir sollten auf Testflle achten, die Randbedingungen, Sonderflle und Grenzen berprfen. Oft machen solche Testflle den weitaus berwiegenden Anteil an Testfllen aus. O-by-One-Fehler verfhren leicht dazu, dass man einen beim Testen als falsch erkannten Wert gleich nach oben oder unten korrigiert, ohne der Ursache des Fehlers vorher genau auf den Grund gegangen zu sein. So etwas kann weitere Fehler nach sich ziehen. Auch Grenzen sollten wir statisch verstehen, nicht nur durch Verfolgen des dynamischen Programmablaufs. Weil die Grenzen hug viel linearer (ohne Schleifen) und mit Konstanten berst sind, ist der dynamische Ablauf meist leichter nachvollziehbar als im Kern. Es ist jedoch wichtig, dass man die Gesamtheit statisch versteht, da sonst der oben erwhnte bergang an der Bruchlinie zwischen abstraktem und konkretem Denken noch schwieriger wird. Um Termination garantieren zu knnen, muss man die Grenzen beachten. berprfungen der Termination fhren fast automatisch dazu, dass man die Grenzen statisch versteht. Nicht zuletzt aus diesem Grund sollte man sich der Termination wirklich vergewissern.
401
6 Vorsicht: Fallen!
Das Aufrumen nach geworfenen Ausnahmen passiert meist an Grenzen. Hierbei ist besondere Vorsicht ntig, weil der genaue Programmzustand in der Regel unbekannt ist. Plausibilittsprfungen fr Daten, die aus der Umgebung kommen, werden oft an Grenzen durchgefhrt und beeinussen die Grenzen. Sie drfen keinesfalls vernachlssigt werden, auch aus Grnden der Sicherheit vor Angrien (siehe unten). Die Programmkonstruktion ist eine intellektuell anstrengende Ttigkeit, die noch dazu fast immer unter Zeitdruck erfolgen muss. Unter diesen Bedingungen ist es durchaus verstndlich, dass man versucht, sich auf das Wesentliche zu konzentrieren und Nebenschlichkeiten eher beiseite zu lassen, so wie es die meisten Menschen machen. Genau deswegen passieren aber so viele Fehler an Grenzen. Erfahrene Softwareentwickler(innen) sehen die Grenzen nicht als Nebensache und ersparen sich deswegen viel Zeit fr das Debuggen. Man braucht groe Erfahrung um zu verstehen, welche Aspekte der Softwareentwicklung wesentlich und welche nebenschlich sind. Die Wichtigkeit der Grenzen sollte man keinesfalls unterschtzen. Falsch angenommene Grenzen bei Zugrien auf Speicherbereiche (beispielsweise Arrays) stellen ein ganz besonders schwerwiegendes Problem dar. Es knnte flschlicherweise auf etwas zugegrien werden, was gar nicht mehr zum Speicherbereich gehrt etwa den tausendsten Eintrag in einem Array, obwohl das Array nur Platz fr zehn Eintrge hat. Bei der Entwicklung von Java wurde viel unternommen, damit so etwas nicht passieren kann. Beispielsweise wird beim Versuch, auerhalb des Indexbereichs auf ein Array zuzugreifen, eine Ausnahme geworfen. Aber auch der Java-Interpreter und das darunter liegende System knnen Fehler haben und in ganz seltenen Fllen den falschen Zugri nicht verhindern. Besonders leicht passiert das auf einfacher und daher schlecht abgesicherter Hardware und Betriebssystemuntersttzung, heute insbesondere auf Smartphones. Durch schreibende Zugrie auerhalb des Speicherbereichs wird etwas verndert, das nichts mit dem Array zu tun hat. Dabei knnen wichtige Daten und vielleicht auch das Programm selbst zerstrt werden. Aus diesem Grund mssen wir besonders darauf achten, dass wir niemals auf etwas zugreifen, das auerhalb der Grenzen liegt. Die wirkliche Gefahr bei Zugrien auerhalb der Grenzen ist die, dass jemand dadurch die Kontrolle ber eine Maschine erlangen kann, der keinen Zugang haben soll. Wenn ein Angreifer einen Fehler kennt, durch den Zugrie auerhalb eines Speicherbereichs mglich sind, kann er ganz
402
6.3 Nebenlugkeit
gezielt solche (fr bliche Anwendungen sinnlose und daher nicht getestete) Daten in ein Programm fttern, sodass bestimmte Teile des gerade ausgefhrten Programms berschrieben werden. Statt der ursprnglich im Programm stehenden Anweisungen werden danach die vom Angreifer eingeschleusten Anweisungen ausgefhrt. Auf diese Weise bekommt der Angreifer Zugang zu allem, worauf das Programm zugreifen darf. Einem Angreifer reicht es, wenn er nur eine einzige Stelle in einem von vielen Programmen kennt, um Zugri auf die Maschine zu erlangen. Daher spielt es keine Rolle, wie klein die Wahrscheinlichkeit fr einen solchen Fehler ist. Immerhin muss es im Falle von Java einen Fehler im Java-Interpreter, im Betriebssystem, oder in der Hardware und einen dazu passenden Fehler in einem Programm geben. Bei der riesigen Gre des Interpreters und Betriebssystems sowie der gigantischen Zahl an Java-Programmen wird sich auch bei sehr kleiner Wahrscheinlichkeit irgendwann ein solcher Fehler zeigen. Entsprechende Angrie nden immer wieder statt. Huger sind derartige Angrie ber Programme in anderen Sprachen wie beispielsweise C, in denen Zugrie auerhalb der Grenzen weniger gut abgesichert sind. Am hugsten sind Angrie ber Puerberlufe. Dabei werden im Programm vom Benutzer eingegebene Daten in einen bestimmten Speicherbereich kopiert. Wenn man nicht genau berprft, ob die eingegebenen Daten im Speicherbereich Platz haben, kann ein Angreifer entsprechend lange (im Normalfall sinnlose) Daten in das Programm fttern, die dann andere Speicherbereiche, vor allem Teile des Programms berschreiben. Seit vielen Jahrzehnten stellen Puerberlufe eine von Angreifern bevorzugte Mglichkeit dar um in ein System einzubrechen. Trotz aller Bemhungen ist es bisher nicht gelungen, Programme in dieser Beziehung sicher zu machen. Sicherheit auf der Ebene von Programmiersprachen und Betriebssystemen reicht dafr nicht aus. Auch wir mssen bei der Programmkonstruktion mitspielen um unseren Programmen keinen Angrispunkt zu bieten. Daher ist es ganz wichtig, dass wir Daten, die von auerhalb kommen, immer auf Plausibilitt prfen. Vor allem mssen wir sicherstellen, dass die Daten dort, wo sie hinkommen, auch Platz nden.
6.3 Nebenlugkeit
Die nebenluge Programmierung erlebt eine Renaissance: Prozessoren in aktuellen Rechnern enthalten zum berwiegenden Teil mehrere Prozessor-
403
6 Vorsicht: Fallen!
Kerne, aber ohne spezielle Untersttzung durch das Programm werden die Mglichkeiten nicht in vollem Umfang genutzt. Nebenluge Programmierung ist eine Form einer solchen Untersttzung. Aber leider fhrt Nebenlugkeit zu einer viel greren Programmkomplexitt, die ohne spezielle Erfahrung in diesem Bereich nicht zu beherrschen ist. Wir wollen betrachten, was nebenluge Programmierung ist, wie sie in Java umgesetzt ist, welche Alternativen es dazu gibt und mit welchen Schwierigkeiten man dabei umgehen muss. 6.3.1 Parallelitt und Nebenlugkeit Zunchst klren wir einige Begrie, die im Alltag manchmal etwas schlampig verwendet und daher gelegentlich miteinander verwechselt werden: Parallelitt: Darunter versteht man die gleichzeitige (= parallele) Ausfhrung mehrerer Programme oder Programmteile auf mehreren Prozessoren, Prozessor-Kernen oder Recheneinheiten. Ziel ist die Leistungssteigerung durch optimale Ausnutzung vorhandener Hardware. Der Begri gibt keine bestimmte Manahme oder Technik vor, wie diese Ausnutzung erfolgt. Man unterscheidet hug feingranulare (durch mehrere parallele Recheneinheiten pro Prozessor-Kern) von grobkrniger Parallelitt (durch mehrere Prozessoren oder Prozessor-Kerne). Heute untersttzen Prozessoren Parallelitt auf beide Arten zugleich. Parallelisierung: Das ist die Aufspaltung eines Programms in mehr oder weniger unabhngige Teile, die parallel ausgefhrt werden knnen. Das Ziel ist ausschlielich die optimale Ausnutzung der Hardware. Man unterscheidet die automatische Parallelisierung, bei der ein Compiler die Aufteilung vornimmt, von der manuellen Parallelisierung, die bei der Programmkonstruktion erfolgt. Auf feingranularer Ebene (einzelne Maschinenbefehle) wird heute fast immer automatisch durch den Compiler und die Hardware parallelisiert siehe Abschnitt 5.3.3. Auf der grobkrnigen Ebene (grere Programmteile) wird in der Java-Programmierung eher selten parallelisiert, weder automatisch noch manuell. Nebenlugkeit: Darunter versteht man die Strukturierung eines Programms auf eine Art und Weise, dass einzelne Teile so miteinander kommunizieren, als ob sie gleichzeitig ausgefhrt werden wrden. Es spielt keine Rolle, ob sie tatschlich gleichzeitig ausgefhrt werden, oder ob die Gleichzeitigkeit nur simuliert wird. Nebenlugkeit
404
6.3 Nebenlugkeit
impliziert also einen bestimmten Programmierstil. Dieser Programmierstil ist beispielsweise gut dafr geeignet, gleichzeitig auf mehrere unabhngige Ereignisse zu warten und rasch auf jedes eingetretene Ereignis zu reagieren, obwohl man im Vorhinein nicht wei, wann welches Ereignis eintritt etwa ein Web-Browser, der gleichzeitig auf den Empfang unterschiedlicher Web-Inhalte wartet. Nebenlugkeit eignet sich zur Parallelisierung auf grobkrniger Ebene. Allerdings ergibt sich Parallelitt nicht in jedem Fall, da wie beim Warten auf mehrere Ereignisse durch Nebenlugkeit nicht unbedingt mehrere Berechnungen gleichzeitig durchgefhrt werden knnen (es wird ja hauptschlich nur gewartet). Das Ziel der Nebenlugkeit ist hug eine Vereinfachung der Softwarestruktur bzw. -architektur, nur gelegentlich die optimale Ausnutzung der Hardware. Nebenlugkeit ist daher ein allgemeinerer Begri als Parallelitt. Auf feingranulare Parallelitt und Parallelisierung gehen wir nicht nher ein, da wir uns beim Programmieren kaum darum kmmern mssen. Parallelitt und Nebenlugkeit werden auf grobkrniger Ebene vor allem durch folgende Techniken untersttzt: Multiprocessing: Ein Prozess (engl. Process, manchmal auch Task genannt) ist die Ausfhrung eines Programms. Beim Multiprocessing knnen mehrere Prozesse gleichzeitig oder derart berlappt laufen, dass es so aussieht, als ob sie gleichzeitig laufen wrden. Jeder Prozess hat seinen eigenen Namensraum, das heit, Variablen und Objekte, die im einen Prozess existieren, sind von anderen Prozessen aus nicht zu sehen. Daher sind Prozesse ziemlich unabhngig voneinander, abgesehen davon, dass gemeinsame Ressourcen wie Dateien von allen Prozessen zusammen verwendet werden. Diese Ressourcen werden jedoch in jedem Prozess anders angesprochen, beispielsweise ber unterschiedliche Streams. Beim Programmieren brauchen wir uns (abgesehen von der Verwaltung gemeinsamer Ressourcen) kaum um Multiprocessing zu kmmern. Wir knnen jedoch auch innerhalb eines Programms bzw. Prozesses neue Prozesse aufspannen eine andere Bezeichnung fr Programme aufrufen. Multithreading: Ein Thread (deutsch etwa Faden) ist ein Ausfhrungsstrang innerhalb eines Prozesses. Jeder Prozess hat mindestens einen Thread. Man spricht von Multithreading wenn ein Prozess mehrere Threads haben kann, die gleichzeitig oder derart berlappt laufen,
405
6 Vorsicht: Fallen!
dass es so aussieht, als ob sie gleichzeitig laufen wrden. Im Unterschied zu Prozessen haben Threads keine eigenen Namensrume, sodass mehrere Threads auf dieselben Variablen und Objekte zugreifen knnen. Nur lokale Variablen (die innerhalb von Methoden und Konstruktoren deklariert wurden) sind in anderen Threads nicht sichtbar. Beim Programmieren mssen wir daher darauf achten, dass sich Threads beim mglicherweise gleichzeitigen Zugri auf gemeinsame Variablen und Objekte nicht gegenseitig behindern. Dafr sind wir selbst beim Programmieren verantwortlich. Multiprocessing im eigentlichen, oben beschriebenen Sinn ist in Java zwar mglich, wird aber absichtlich stark erschwert. Der Grund besteht darin, dass man dabei von Java aus einen Java-Interpreter oder ein anderes Programm auerhalb der Welt von Java startet. Beides ist unerwnscht, weil man dadurch die Schutzmechanismen von Java umgeht und Zugang zu Ressourcen bentigt, auf die man keinen Zugri haben soll. Multithreading innerhalb eines Java-Interpreters wird dagegen durch eine Reihe von Manahmen untersttzt. Listing 6.6 zeigt ein Beispielprogramm, in dem zehn Threads unabhngig voneinander gleiche Aufgaben bearbeiten. Die Methode main in TestMultithreading erzeugt zehn Instanzen der Klasse Worker, jeweils mit einer anderen Instanz von Counter als Argument, und zehn Instanzen der vordenierten Klasse Thread, die jeweils einen eigenen Thread darstellen. Das an den Konstruktor von Thread bergebene Argument muss so wie Instanzen von Worker vom Typ Runnable sein und die in diesem Interface spezizierte Methode run denieren. Sobald die Nachricht start an den Thread geschickt wird, beginnt der Thread zu laufen und die Methode run im Worker auszufhren. Diese Methode ruft wiederholt increment in der Instanz von Counter auf. Wenn die Ausfhrung von run zu Ende ist, dann ist auch der entsprechende Thread beendet. Die Methode increment in Counter macht etwas Eigenartiges: Bevor die beiden Variablen x und y erhht werden, wird berprft, ob sie ungleiche Werte enthalten und gegebenenfalls eine Meldung ausgegeben und die Variablenwerte auf 0 gesetzt. Das scheint unntig, denn aus der Betrachtung von Counter ergibt sich oensichtlich, dass x und y niemals ungleiche Werte enthalten knnen. Ausfhrungen des Programms besttigen, dass auch bei sehr vielen berprfungen x und y niemals voneinander verschieden sind.
406
6.3 Nebenlugkeit
Listing 6.6: Java-Programm mit mehreren Threads class Counter { private int x = 0, y = 0; public void increment() { if (x != y) { [Link]("x = " + x + "; y = " + y); x = y = 0; } x++; y++; } } class Worker implements Runnable { private Counter counter; public Worker(Counter c) { counter = c; } public void run() { for (int i = 0; i < 100000; i++) [Link](); } } public class TestMultithreading { public static final void main(String[] args) { for (int i = 0; i < 10; i++) { Worker w = new Worker(new Counter()); new Thread(w).start(); } } }
Eine kleine nderung des Programms hat schwerwiegende Auswirkungen. Wenn wir den Rumpf von main durch folgende Zeilen ersetzen, teilen sich alle Worker eine Instanz von Counter: Counter counter = new Counter(); for (int i = 0; i < 10; i++) { Worker w = new Worker(counter); new Thread(w).start(); }
407
6 Vorsicht: Fallen!
Mit dieser nderung zeigen Programmlufe, dass sich x und y in dieser einen Instanz sehr wohl voneinander unterscheiden knnen. Mehrere Programmlufe knnen Unterschiede zwischen x und y an ganz unterschiedlichen Stellen entdecken. Die Ursache liegt darin, dass mehrere Threads gleichzeitig increment im selben Objekt ausfhren und dabei auf die beiden Variablen zugreifen knnen, sodass beispielsweise x mit y genau dann verglichen wird, wenn x schon verndert wurde, y aber noch nicht. Das hat eine ganze Reihe eigenartiger und in der Regel unerwnschter Auswirkungen. In der unvernderten Version von Listing 6.6 passiert das nicht, weil jeder Thread auf einem anderen Objekt operiert. 6.3.2 Race-Conditions und Synchronisation Das, was in obigem Beispiel passiert, ist ein typischer Fall einer RaceCondition. Das heit, die Ergebnisse von Berechnungen knnen davon abhngen, ob ein Thread schneller ist als ein anderer. Mehrere Ausfhrungen desselben Programms knnen zu unterschiedlichen Ergebnissen fhren, weil der zeitliche Ablauf jeden einzelnen Threads nicht genau genug vorherbestimmt ist. Je nach Situation ist ein Thread manchmal etwas schneller oder langsamer als sonst. Winzigste Unterschiede reichen aus. In der abgenderten Version des Beispiels in Listing 6.6 fhren unter anderem folgende zeitliche Abhngigkeiten zu unerwarteten Werten: Zwei Threads fhren die Anweisung x++; oder y++; etwa gleichzeitig aus. Diese Anweisungen lesen zuerst den Wert der Variablen, erhhen ihn und schreiben dann den neuen Wert in die Variable zurck. Wenn beide Threads gleichzeitig denselben Wert der Variablen lesen, schreiben sie auch denselben (um eins erhhten) Wert zurck, das heit, die Variable wird in zwei Ausfhrungen von increment insgesamt nur um Eins erhht. Falls so etwas aufgrund kleinster Zeitunterschiede nur bei einer der beiden Variablen passiert, wird eine Variable um Eins und die andere um Zwei erhht. Es ist mglich, dass ein Thread nach dem Lesen und vor dem Zurckschreiben eines Variablenwertes vorbergehend unterbrochen wird und danach einen Wert zurckschreibt, der (nach zwischenzeitlicher Ausfhrung von increment durch andere Threads) schon lange nicht mehr aktuell ist. Ein Thread kann x mit y gerade in dem Augenblick vergleichen, in dem ein anderer Thread x schon erhht hat, y aber noch nicht.
408
6.3 Nebenlugkeit
Das Ausgeben einer Zeichenkette dauert wesentlich lnger als eine normale Ausfhrung von increment. Dadurch ist die Wahrscheinlichkeit hoch, dass mehrere Threads (oft so viele, wie Prozessorkerne vorhanden sind) einen Unterschied zwischen x und y feststellen bevor die Unterschiede beseitigt werden. Wenn man mehrere Probelufe macht und die Ausgaben genau betrachtet, bekommt man ein Gefhl dafr, welche dieser Ursachen bei welchen Werten aufgetreten sind. So lernt man zu verstehen, was durch Nebenlugkeit alles passieren kann. Auch bei Verwendung von nur einer statt der zwei Variablen x und y gibt es Probleme; auch dann entstehen Fehler beim Zhlen der Aufrufe von increment. Wir verwenden hier die beiden Variablen nur um die Probleme besser sichtbar werden zu lassen. Der wichtigste Lsungsansatz besteht darin, dafr zu sorgen, dass eine Variable niemals fr mehrere Threads gleichzeitig zugreifbar ist. Das unvernderte Programm in Listing 6.6 macht genau das, indem jeder Thread ein anderes Objekt bearbeitet. Wenn man beispielsweise die Anzahl der Aufrufe von increment ermitteln will, kann man das auch mit einem eigenen Zhler pro Thread bewerkstelligen und am Ende in nur einem Thread die einzelnen Zhlerwerte aufsummieren. Schematisch kann man sich das etwa so vorstellen: Daten vorbereiten und Threads aufspannen unabhngiger Thread 1 unabhngiger Thread n Zwischenergebnisse zusammenfassen Dabei bezieht sich der Begri unabhngig darauf, dass die im Thread verwendeten Daten nicht gleichzeitig von anderen Threads verwendet werden. Fr die meisten Aufgaben kann man, wenn man sich bemht, eine Lsung nach diesem Schema nden, wobei die in den unabhngigen Threads zu lsenden Teilaufgaben mglichst umfangreich sein und etwa gleich lange brauchen sollen. Allen anderen Lsungsanstzen, die wir gleich ansprechen werden, sind solche Lsungen vorzuziehen: Sie sind einfach zu verstehen und knnen die Mglichkeiten paralleler Hardware sehr gut ausntzen. Nicht immer ist es sinnvoll, die Objekte, auf denen unterschiedliche Threads operieren, voneinander zu trennen. Fr solche Flle bietet Java Mglichkeiten zur Synchronisation von Threads. Eine Mglichkeit besteht darin, Methoden, die in mehreren Threads gleichzeitig aufgerufen werden knnten, mit dem Modier synchronized zu denieren:
409
6 Vorsicht: Fallen!
public synchronized void increment() {...} Zur Laufzeit sorgt Java dafr, dass solche Methoden auf demselben Objekt nicht gleichzeitig, sondern nur hintereinander ausgefhrt werden knnen. Wird increment von mehreren Threads ungefhr gleichzeitig aufgerufen, so werden die Threads in eine Warteliste gestellt und knnen mit der Ausfhrung erst fortfahren, wenn alle Threads vor ihnen die Ausfhrung der Methode schon beendet haben. So ist garantiert, dass die oben skizzierten Probleme nicht auftreten knnen. Wenn man diese Lsung in der modizierten Variante des Programms in Listing 6.6 einsetzt, kann man kaum mehr von einer parallelen Programmausfhrung sprechen: Die wesentlichen Programmteile werden alle hintereinander, also sequentiell ausgefhrt. Wahrscheinlich dauert die Ausfhrung dieses Programms deutlich lnger als die eines vergleichbaren Programms ohne Nebenlugkeit, weil auch das Aufspannen der Threads und vor allem die sehr hug ntige Synchronisation viel Rechenzeit verschlingt. Die Synchronisation ber synchronized Methoden ist nur sinnvoll, wenn die Ausfhrung dieser Methoden im Vergleich zum gesamten Programm nur einen ganz kleinen Teil der Rechenzeit bentigt. In diesem Fall ist die Wahrscheinlichkeit dafr, dass mehrere Aufrufe fast gleichzeitig erfolgen, recht gering, und die Laufzeiteinbuen durch die Synchronisation bleiben in einem vertretbaren Rahmen. Listing 6.7 zeigt eine komplexere Form der Synchronisation, in der ein Thread fr lngere Zeit warten muss. Neben increment gibt es in der Klasse Counter eine zweite synchronized Methode. Zu jedem Zeitpunkt kann im selben Objekt hchstens eine dieser beiden Methoden ausgefhrt werden, wodurch niemals mehrere Threads gleichzeitig auf x zugreifen knnen. Zustzlich wollen wir, dass ein Thread, der wow ausfhren mchte, solange wartet, bis x mindestens einen Wert von 200.000 hat. Dazu verwenden wir die in Object denierten Methoden wait und notifyAll: Nach einem Aufruf von wait werden Ausfhrungen von synchronized Methoden auf dem Objekt vorbergehend wieder mglich, aber der aktuelle Thread muss in der Regel so lange warten, bis er durch die Ausfhrung von notify oder notifyAll auf demselben Objekt durch einen anderen Thread wieder aufgeweckt wird. Der Unterschied zwischen notify und notifyAll besteht nur darin, dass notify irgendeinen auf dem Objekt wartenden Thread aufweckt, whrend notifyAll alle aufweckt. Aufgeweckte Threads drfen natrlich
410
6.3 Nebenlugkeit
Listing 6.7: Synchronisation und lngeres Warten (Worker aus Listing 6.6) class Counter { public static final int BARRIER = 200000; private int x = 0, y = 0; public synchronized void increment() { x++; y++; if (x == BARRIER) notifyAll(); } public synchronized void wow() { while (x < BARRIER) { try { wait(); } catch(InterruptedException ex) { return; } } [Link]("Wow! Schon bei " + x + "!"); } } public class TestWaiting { public static final void main(String[] args) { Counter counter = new Counter(); for (int i = 0; i < 10; i++) { Worker w = new Worker(counter); new Thread(w).start(); } for (int i = 0; i < 3; i++) [Link](); } }
erst dann weitermachen, wenn keine andere synchronized Methode auf dem Objekt mehr luft. Wie in wow wird wait praktisch immer in einer Schleife aufgerufen, da Threads in Ausnahmefllen auch ohne vorherige Ausfhrung von notify oder notifyAll aufgeweckt werden knnen. Auerdem muss man die Ausnahme InterruptedException abfangen, die auftritt, wenn ein wartender Thread abgebrochen wird. Ziel der Synchronisation sind atomare Aktionen. Das bedeutet, dass eine Gruppe von Anweisungen (welche die atomare Aktion bildet) als eine Einheit ausgefhrt wird und Objektzustnde, die kurzzeitig zwischen der Ausfhrung von zwei Anweisungen der Gruppe bestehen, in keinem anderen Thread sichtbar werden. In Listing 6.7 bleibt der Objektzustand,
411
6 Vorsicht: Fallen!
in dem x bereits erhht wurde, y aber nicht, allen anderen Threads verborgen. Atomare Aktionen sind das eigentliche Ziel der Synchronisation. Leider ist es in Java gar nicht so einfach, sichere atomare Aktionen zu erreichen. Beispielsweise knnte zwischen der Ausfhrung von x++ und y++ eine Ausnahme geworfen werden und somit der zwischenzeitliche Zustand doch sichtbar werden. Man muss auch darauf achten, dass durch einen Aufruf von wait atomare Aktionen unterbrochen werden. Zustnde zum Zeitpunkt des Aufrufs von wait werden sichtbar. Trotz Synchronisation ist die nebenluge Programmierung eine fehleranfllige Angelegenheit. Synchronisation braucht man in Java fr jeden Zugri auf eine Variable, wenn mehrere Threads auf die Variable zugreifen knnten, also auch dann, wenn die gesamte atomare Aktion nur im einfachen Lesen oder Schreiben einer Variablen besteht. Das ist notwendig, weil das Java-System andernfalls Optimierungen durchfhrt, durch die ein Thread manchmal nur veraltete Variableninhalte zu sehen bekommt Variableninhalte, die durch andere Threads schon lngst verndert wurden. Fr genau diese Form von sehr einfachen atomaren Aktionen gibt es in Java eine andere, einfachere Form der Synchronisation: Lese- und Schreibzugrie auf Variablen, die mit dem Modier volatile deklariert wurden, werden automatisch als synchronisiert betrachtet. In manchen Fllen ist das sinnvoll, aber grere atomare Aktionen, die Race-Conditions verhindern, lassen sich damit kaum durchfhren. 6.3.3 Gegenseitige Behinderung Die Synchronisation nebenluger Threads kann zu einer ganzen Reihe von Problemen fhren. Synchronisation bedeutet im Wesentlichen, dass ein Thread behindert wird, damit ein anderer Thread seine Aufgabe ungehindert ausfhren kann. Die Threads behindern sich also gegenseitig. Man kann eine Analogie zum Straenverkehr herstellen, wo Verkehrsregeln einige Verkehrsteilnehmer behindern, damit andere ungehindert vorankommen. Solche Verkehrsregeln mssen wohlberlegt sein, damit nicht der gesamte Verkehr ins Stocken gert. Sie mssen gefhrliche Situationen vermeiden, aber auch gerecht sein, damit nicht bestimmte Verkehrsteilnehmer stndig benachteiligt werden. Man muss Straen so planen, dass man in jeder Situation unter Beachtung der Verkehrsregeln irgendwann mglicherweise nach einer bestimmten Wartezeit wieder weiterkommt. Auch durch Synchronisation werden gefhrliche Situationen vermieden. Man muss nebenluge Systeme (analog zu den Straen) so bauen, dass
412
6.3 Nebenlugkeit
alle Threads irgendwann ihre Aufgaben erfllen knnen. Diese Bedingung ist keineswegs automatisch erfllt. Beispielsweise kann ein Thread alles blockieren, sodass andere Threads nie zum Zug kommen. Es ist auch mglich, dass mehrere Threads sich gegenseitig blockieren, sodass keiner der Threads zum Zug kommt und das gesamte System steht. Wir mssen sicherstellen, dass das System am Leben bleibt und sogenannte LivenessProperties erfllt. Dabei mssen wir folgende Situationen verhindern: Starvation: Das bedeutet, dass bestimmte Ressourcen, beispielsweise der Zugri auf eine bestimmte Variable, hug und fr lange Zeit von bestimmten Threads blockiert wird, sodass andere Threads kaum Zugang zu diesen Ressourcen bekommen. Diese anderen Threads knnen ihre Aufgaben nicht mehr zeitgerecht erfllen, sie verhungern also schn langsam. Um Starvation zu vermeiden darf der Zugang zu Ressourcen nicht fr lngere Zeit blockiert werden. Dementsprechend drfen synchronized Methoden zur Erledigung ihrer Aufgaben nur kurze Zeit bentigen. Livelock: Bei einem Livelock versuchen mehrere Threads stndig erfolglos, miteinander in Kontakt zu treten. Beispielsweise wartet Thread A darauf, dass Thread B einen bestimmten Wert in die Variable x schreibt, und Thread B wartet darauf, dass Thread A einen Wert in y schreibt. Statt wirklich zu warten lesen die beiden Threads die Variablen immer wieder in der Honung, dass der andere Thread den Wert in der Zwischenzeit verndert hat. Beide Threads wollen zuerst den erwarteten Wert aus einer Variablen lesen, bevor sie die jeweils andere Variable ndern. Das heit, die beiden Threads sind am Leben und sehr aktiv, aber in der Berechnung geht nichts weiter weil beide Threads ihre Zeit nur mit aktivem Warten verbringen. Deadlock: hnlich wie bei einem Livelock wartet zum Beispiel ein Thread A darauf, dass Thread B etwas macht, und Thread B wartet darauf, dass A etwas macht. Anders als bei einem Livelock sind die Threads jedoch nicht aktiv, sondern hngen in einer Warteliste. Dort bleiben sie ewig hngen. Zur Konkretisierung des Beispiels nehmen wir an, dass A gerade eine synchronisierte Methode in einem Objekt x ausfhrt und B die entsprechende Methode in einem Objekt y . Nun mchte A dieselbe Methode auch in y und B dieselbe Methode in x ausfhren. Allerdings geht das nicht sofort, weil die Methode in x gerade von A und jene in y gerade von B ausgefhrt wird, und nicht
413
6 Vorsicht: Fallen!
mehrere Threads gleichzeitig eine synchronisierte Methode ausfhren drfen. Daher werden beide Threads in Wartelisten gehngt und warten darauf, dass die jeweils andere fertig wird. Und wenn sie nicht abgebrochen wurden, warten sie noch heute. Um die Gefahr von Starvation zu verringern sollen synchronisierte Methoden nur kurz laufen. Wegen der Gefahr von Livelocks soll man aktives Warten vermeiden, auch um beim Warten keine unntige Rechenzeit zu verschwenden. Diese Vorgehensweisen verringern die Wahrscheinlichkeit von Starvation und Livelocks, knnen sie aber nicht gnzlich vermeiden. Es gibt auch keine einfach zu befolgende Regel zur Vermeidung von Deadlocks. Die mglichen Ursachen sind zu vielfltig. Oft geht man einfach so vor, dass man bei der ersten Konstruktion eines Programms zwar peinlich genau auf atomare Aktionen und die Vermeidung von Race-Conditions achtet, aber Liveness-Properties und insbesondere Deadlocks nur am Rande bercksichtigt. Dafr legt man bei den Testlufen besonderes Augenmerk auf Liveness-Properties. Es fllt hoentlich auf, wenn das System aufgrund solcher Probleme nicht richtig funktioniert. Erst wenn man die Ursachen der Probleme erkannt hat, kann man sie oft unter sehr hohem Aufwand beseitigen. Wirklich empfehlenswerte Techniken zur Frherkennung und Vermeidung mglicher Deadlocks gibt es leider nicht. Seit kurzem gibt es auch Werkzeuge, die ber formale Techniken (Modell-Checking) mgliche Deadlocks in JavaProgrammen herausnden knnen. Aber diese Werkzeuge sind noch sehr fragil und funktionieren nicht immer und berall. Leider ist auch das Testen nebenluger Programme recht kompliziert. Da kleinste Unterschiede im zeitlichen Verhalten zu ganz anderen Ergebnissen fhren knnen, ist es mit einigen wenigen Testlufen nicht getan. Es sind viele Testlufe auf unterschiedlicher Hardware und unter unterschiedlichen Betriebssystemen ntig. In letzter Zeit entstehen immer mehr Softwarepakete, Technologien und Spracherweiterungen um auch weniger erfahrenen Personen die nebenluge Programmierung zu ermglichen. Tatschlich sind Vereinfachungen mglich. Das Hauptproblem sind jedoch nicht kleine Mngel in den Programmiersprachen, sondern die hohe Komplexitt der nebenlugen Programmierung an sich. Auch mit den besten Werkzeugen lsst sich diese Komplexitt nicht beseitigen. Man bentigt gnzlich neue Berechnungsmodelle um nennenswerte Verbesserungen zu erzielen. Damit verbunden sind neue Programmierparadigmen. Allerdings kann man etablierte Pa-
414
radigmen nicht von heute auf morgen durch neue ersetzen, da damit viel Wissen und Erfahrung ber die Entwicklung guter Software verloren gehen wrde. Es wird daher noch lange dauern, bis die nebenluge Programmierung genauso einfach sein wird wie die sequentielle. Zusammengefasst kann man nur eine Empfehlung geben: Nebenluge Programmierung ist nichts fr Anfnger also Hnde weg davon.
415
6 Vorsicht: Fallen!
miteinander kombinierbar sein mssen. Fr beide Aspekte (einfache Erfassbarkeit und Kombinierbarkeit) ist es wichtig, dass alle Kontrollstrukturen einen klar vorgegebenen Anfangspunkt und einen genauso klar vorgegebenen Endpunkt besitzen. Die Betonung liegt dabei auf einen. Damit wird die Freiheit bei der Programmierung gelegentlich eingeschrnkt. Das Prinzip der strukturierten Programmierung sagt sehr deutlich, dass es besser ist, die Einschrnkungen in Kauf zu nehmen um eine einfachere Erfassbarkeit und Kombinierbarkeit der Sprachkonstrukte zu erreichen. Viele typische Fallen haben damit zu tun, dass wir aus Grnden der Flexibilitt oder einfach nur um weniger Code schreiben zu mssen die Richtlinie von nur einem Anfangs- und Endpunkt vernachlssigen. Fast alle Sprachen bieten Gelegenheiten dazu. Das Ergebnis sind Programme, die schwer zu lesen sind bzw. bei denen man leicht kleine aber wichtige Feinheiten im Programmablauf bersieht. Mit der Zeit werden dadurch Fehler in das Programm eingeschleust. Folgende derartige Fallen sind fr viele prozedurale und objektorientierte Programme typisch: Goto: Das ist ein Klassiker und quasi der Gegenpol zur strukturierten Programmierung: In vielen vor allem lteren Programmiersprachen kann man durch den Befehl goto x die Ausfhrung abbrechen und an einer anderen Stelle, die durch x bezeichnet ist und innerhalb derselben Prozedur liegt, fortsetzen. Damit kann der Programmuss beliebig kontrolliert werden. Beispielsweise kann man damit sehr einfach Schleifen realisieren, aber der Phantasie sind kaum Grenzen gesetzt. Die unkontrollierte Verwendung von goto macht fast jedes Programm unverstndlich. Man darf goto nicht mit einem Prozedur- oder Methodenaufruf verwechseln. Bei einem solchen Aufruf wird eine neue lokale Umgebung aufgebaut, darin der Code der Prozedur oder Methode ausgefhrt und am Ende mglicherweise ein Ergebnis an den Aufrufer zurckgegeben. Alle Bedingungen der strukturierten Programmierung sind dabei erfllt. Ein goto baut keine neue Umgebung auf, sondern operiert in der Umgebung der Programmstelle, an die gesprungen wird. Es wird beispielsweise nicht kontrolliert, ob die an dieser Stelle verwendeten Variablen schon initialisiert sind. Es ist auch keine Rckkehr an die Stelle vorgesehen, an der goto ausgefhrt wurde. Fall-Through: In Listing 2.24 haben wir ein Java-Beispiel dafr gesehen, wie man in einer switch-Anweisung Code schreiben kann,
416
der in mehreren Zweigen, also fr mehrere case-Klauseln ausgefhrt wird. Wird die Anweisungssequenz einer case-Klausel nicht mit break abgeschlossen, fllt man automatisch in den Code fr die nchste case-Klausel (Fall-Through). Das ist ein Beispiel fr einen schlechten Programmierstil, welcher der strukturierten Programmierung klar widerspricht. Man rechnet nicht damit, dass ber mehrere Wege an den Beginn des Codes einer case-Klausel verzweigt wird, was zu Fehlern fhrt. Die Probleme hneln denen von goto, sind aber meist nicht ganz so gravierend. In aktuellen Programmiersprachen gibt es keinen Grund mehr fr Fall-Through. Es ist vielleicht notwendig, die eine oder andere Zeile mehr in den Programmcode zu schreiben. Diese zustzlichen Zeilen sind jedoch im Hinblick auf die Lesbarkeit und Wartbarkeit notwendig und keineswegs berssig. Der Compiler nutzt Optimierungen, um gemeinsame Programmteile zusammenzulegen. Man gewinnt durch Fall-Through also auch nichts an Laufzeitezienz. Break: Am Ende jeder case-Klausel in einer switch-Anweisung ist eine break-Anweisung notwendig, um Fall-Through zu vermeiden. Allerdings ist break auch zum vorzeitigen Ausstieg aus einer Schleife verwendbar. Eine solche Verwendung ist zu vermeiden, da dadurch das Ziel eines einzigen, klar denierten Endpunkts verletzt wird. Manchmal ist zustzlicher Programmcode notwendig (etwa eine zustzliche Variable und bedingte Anweisung zum Vergleich des Variableninhalts) um eine solche break-Anweisung zu vermeiden. Diesen meist sehr kleinen zustzlichen Aufwand soll man fr eine saubere Programmstruktur in Kauf nehmen. Continue: In einer Schleife kann continue verwendet werden um noch vor Beendigung einer Iteration fr die nchste Iteration an den Anfang der Schleife zurckzuspringen. Die hnlichkeit von continue mit goto fllt sofort auf. Am problematischsten ist die Tatsache, dass Seiteneekte, die gegen Ende der Schleife passieren sollten, wegen der Beendigung der Iteration nicht passieren was meist beabsichtigt ist. Aber eine continue-Anweisung irgendwo im Schleifenrumpf bersieht man leicht und fgt bei Programmnderungen auch Seiteneekte, die in jeder Iteration passieren mssen, am Ende des Schleifenrumpfs ein. Sogar wenn man die continue-Anweisung bemerkt, sind Programmnderungen schwierig, weil man die Struk-
417
6 Vorsicht: Fallen!
tur des Schleifenrumpfs dafr meist komplett umschreiben muss. Daher sollte man, wie zur Vermeidung von break-Anweisungen in Schleifenrmpfen, zustzlichen Programmcode fr eine saubere Programmstruktur in Kauf nehmen. Die Gefahren von break hneln denen von continue, jedoch sind die Auswirkungen von continue oft schwerwiegender, weil am Ende eines Schleifenrumpfs oft Vorbereitungen fr die nchste Iteration getroen werden, die nach Ausfhrung einer break-Anweisung keine Rolle spielen. Return: Mit einer return-Anweisung steigt man aus einer Methode aus. Wenn die Methode Ergebnisse zurckgibt, ist ein return notwendig. Problematisch ist jedoch die Tatsache, dass man durch return die Methode an beliebiger Stelle verlassen kann. Um Unklarheiten zu vermeiden sollte man return-Anweisungen nur an solchen Stellen einsetzen, wo man sie sich als Programmierer erwartet. Das ist vor allem das Ende der Methode. Aber auch am Ende einzelner Zweige von bedingten Anweisungen stehen hug return-Anweisungen. Die Auswirkungen hneln denen von break zum Ausstieg aus einer Schleife, sind aber meist weniger gravierend, da nach Ausfhrung einer return-Anweisung die lokalen Variablen der Methode ohnehin nicht mehr gltig sind. Trotzdem muss man darauf achten, dass vor Ausfhrung der return-Anweisung alle Objektvariablen die gewnschten Werte enthalten. Ausnahmen: Auch das Werfen einer Ausnahme unterbricht den Kontrolluss und steht damit ganz klar im Widerspruch zur strukturierten Programmierung. Solange Ausnahmen wirklich nur in seltenen Ausnahmefllen geworfen werden, ist dagegen nichts einzuwenden. Fr die normalen Flle kann das Programm noch immer schn strukturiert sein. Ausnahmen knnen sogar helfen, die Programmstruktur zu verbessern, indem der Code frei von zahlreichen berprfungen und Sonderbehandlungen fr seltene Spezialflle bleibt. Andererseits werden Ausnahmen manchmal nicht nur in Ausnahmefllen geworfen, sondern ganz bewusst zur Umgehung des normalen Kontrollusses quasi als goto, das auch ber mehrere Methodenaufrufe hinweg funktioniert. Davon ist dringend abzuraten. Ein solches Programm ist sehr schwer zu verstehen und zu warten. Dangling-Else: if-Anweisungen gibt es in der Form mit und ohne elseZweig. In einer Anweisung der Form if(a)if(b) C ;else D; ist
418
einem Leser oft nicht klar, ob das else sich auf das erste oder zweite if bezieht. Das nennt man Dangling-Else-Problem. In der Sprachdenition ist klar geregelt, dass sich das else in diesem Fall auf das zweite (innere) if bezieht. Ein Leser wird aber oft dadurch in die Irre geleitet, dass die Formatierung des Codes eine andere Strukturierung widerspiegelt als tatschlich vorhanden ist, etwa so: if(a) if(b) C; else D; An der Formatierung kann man erkennen, dass etwas anderes gemeint war, als im Code steht. Solche Situationen, die leicht bei Programmnderungen entstehen, vermeidet man am besten durch geschwungene Klammern um die einzelnen Programmzweige. Gelegentlich spricht man verallgemeinernd immer dann von einem Dangling-Else-Problem, wenn man im Programmcode den hinteren Teil einer Anweisung (z.B. das else) nicht auf einen Blick mit dem Beginn dieser Anweisung verbinden kann, oder wenn der Beginn berhaupt fehlt. Anfangs- bzw. Endpunkt der Kontrollstruktur hngen nicht klar zusammen. Zur Vermeidung sollte man auf die richtige Formatierung achten, bei der die Intuition mit der tatschlichen Programmstruktur bereinstimmt und einzelne Codestcke nicht zu lang sind. Die strukturierte Programmierung lsst nur zusammen mit der richtigen Formatierung ihre Vorteile wirksam werden. 6.4.2 Typische Fallen objektorientierter Sprachen Die objektorientierte Programmierung bietet eine Vielzahl an Mglichkeiten zur Strukturierung von Programmen. Aber gerade diese Vielfalt stellt auch eine Gefahrenquelle dar. Man kann leicht etwas falsch verstehen oder falsch verwenden. Die Folgen knnen schwerwiegend sein, whrend die Ursachen oft unklar bleiben. Folgende Themenbereiche spielen eine Rolle: Ersetzbarkeit: Die objektorientierte Programmierung lebt vor allem davon, dass Instanzen von Untertypen verwendbar sind, wo Instanzen von Obertypen erwartet werden. Schwere Fehler einstehen, wenn man Ersetzbarkeit flschlicherweise annimmt. Der Compiler erkennt und verhindert Fehler bezglich der Ersetzbarkeit, die sich in der Signatur widerspiegeln. Insbesondere muss die Signatur einer berschrei-
419
6 Vorsicht: Fallen!
benden Methode jener der berschriebenen Methode im Wesentlichen entsprechen. Aber das in Kommentaren beschriebene Verhalten von Unter- und Obertypen ist fr den Compiler unverstndlich und nicht berprfbar. Darum mssen wir uns beim Programmieren selbst kmmern. Wir mssen sicherstellen, dass sich ein Objekt jedes Untertyps so verhlt, wie wir es uns von einem Objekt eines Obertyps erwarten. Die Ursachen andernfalls entstehender Fehler sind schwer zu nden und zu beseitigen. Kommentare sind von entscheidender Bedeutung. Vergessene, veraltete oder unverstndlich formulierte Kommentare knnen groe Software-Projekte scheitern lassen. Zusicherungen: Kommentare treten vor allem in Form von Zusicherungen auf Methoden auf. Das ist gut so. Aber manchmal verwendet man Zusicherungen um komplizierte Bedingungen fr die Verwendung von Methoden festzulegen, damit die Methoden etwas einfacher zu implementieren sind. Das ist keine gute Idee, weil sich die Komplexitt des Programms durch zustzlichen Code beim Senden entsprechender Nachrichten meist unntig erhht. Kovarianz: Einige wenige Probleme lassen sich in der objektorientierten Programmierung prinzipiell nicht auf typsichere Weise lsen. Das betrit sogenannte kovariante Probleme, bei denen wir uns wnschen wrden, dass ein formaler Parameter in einer berschreibenden Methode ein (ungleicher) Untertyp des entsprechenden Parametertyps der berschriebenen Methode ist. In Kapitel 3 haben wir als Beispiel dafr die Methode equals betrachtet, die in Object deniert ist und in vielen Klassen berschrieben wird. Der formale Parameter dieser Methode ist immer Object. Eigentlich wrden wir uns wnschen, dass der Typ des formalen Parameters jeweils gleich der Klasse wre, in der die Methode steht. Aus prinzipiellen Grnden ist das jedoch in keiner Programmiersprache mglich, die stark typisiert ist und Ersetzbarkeit auf diesem Parameter untersttzt. Glcklicherweise betrit dieses Problem nur wenige Methoden. Als Falle stellen sich weniger die kovarianten Probleme an sich dar, als viel mehr die zahlreichen Lsungsversuche. Viele kovariante Probleme lassen sich durch eine etwas andere, verallgemeinernde Formulierung umgehen. Aber die Lsungsversuche enden hug in fehleranflligen Code-Stcken, die nur unter einer Vielzahl komplizierter Bedingungen verwendbar sind.
420
Cast: Ein Lsungsansatz fr kovariante Probleme besteht in der Verwendung von Casts auf Referenztypen. Obwohl Casts zur Laufzeit auf Korrektheit berprft werden es wird sichergestellt, dass das Objekt tatschlich den gewnschten Typ hat sind Casts generell sehr fehleranfllig. Meist wissen wir erst zur Laufzeit, welchen dynamischen Typ das Objekt hat. Die Ausnahme, die bei einem falschen dynamischen Typ geworfen wird, knnen wir in der Regel nicht auf sinnvolle Weise abfangen. Sie fhrt nicht selten zum Programmabbruch. Daher sollten wir Casts so gut es geht vermeiden. Generizitt: Eine Lsung vieler solcher Probleme scheint die Generizitt zu bieten. Mittels Generizitt lassen sich sowohl so manche kovariante Probleme lsen als auch viele Casts vermeiden. Vor allem zur Realisierung von Datenstrukturen ist Generizitt heute unverzichtbar. Eine Eigenschaft kann Generizitt alleine aber nicht bieten nmlich Ersetzbarkeit. Also gerade der Bereich, in dem die objektorientierte Programmierung die grten Strken hat, ist durch die Generizitt nicht abgedeckt. Wenn man eine auf Ersetzbarkeit beruhende Lsung gegen eine auf Generizitt beruhende austauscht (beispielsweise um Casts zu vermeiden), so verzichtet man dabei auf Ersetzbarkeit. Da die Ersetzbarkeit fr die Wartung sehr vorteilhaft ist, sollte man nicht ohne wichtigen Grund darauf verzichten. Sichtbarkeit: Einschrnkungen der Sichtbarkeit (etwa durch private) bewirken genau das, was der Begri ausdrckt: Sie schrnken uns in der Freiheit beim Programmieren ein. Das ist der Grund, warum wir gelegentlich gerne auf diese Einschrnkung verzichten. Dabei vergessen wir aber, dass die Einschrnkung einen Sinn hat: Sie hilft Objekte voneinander zu entkoppeln und ntige Programmnderungen lokal zu halten. Das ist eine wichtige Voraussetzung fr gute Wartbarkeit. Deswegen sollen wir die Sichtbarkeit niemals unntig weit ausdehnen. Einmal ausgedehnt lsst sich die Sichtbarkeit nur mehr sehr schwer wieder auf einen kleineren Bereich beschrnken. Vererbung: Die Vererbung zwischen Klassen erfllt eine wichtige Funktion. Sie erlaubt Unterklassen Methoden ohne Neudenition aus Oberklassen zu bernehmen. Aus Oberklassen ererbte Methoden knnen auf private Variablen der Oberklassen zugreifen, die in den Unterklassen nicht sichtbar sind. Ein bertriebener Einsatz von Vererbung hat aber auch Schattenseiten. Es bringt kaum Vorteile, wenn man
421
6 Vorsicht: Fallen!
von wenig stabilen Klassen ableitet, da diese sich hug so ndern, dass auch die Unterklassen davon betroen sind. Wenn man unter allen Umstnden Vererbung einsetzen will, obwohl das in einer bestimmten Situation schwierig ist, luft man Gefahr, dass man dabei die Ersetzbarkeit verletzt. Das ist unbedingt zu vermeiden. Ersetzbarkeit ist in jedem Fall wichtiger als das Erben einiger Methoden. Ezienz: Natrlich mchten wir mglichst eziente Programme schreiben. Aber ein unkontrollierter Ezienz-Wahn ist unbedingt zu vermeiden. Manchmal versucht man mit allen Mitteln (etwa durch Denition von Methoden als static, final oder private) die Notwendigkeit von dynamischem Binden zu vermeiden, weil man wei, dass statisches Binden um eine Spur ezienter ist. Derartige Bemhungen bewirken jedoch sehr oft genau das Gegenteil. Sogar wenn das Programm minimal ezienter werden sollte, verliert man viel. Dynamisches Binden ist ja die Basis der Ersetzbarkeit. Ohne dynamisches Binden sind die Programme weitaus schwieriger zu warten. Funktionalitt: Die objektorientierte Programmierung beruht auf der Simulation von Objekten aus der realen Welt. Es passiert leicht, dass man zu viele oder die falschen Aspekte der realen Welt in einem zu hohen Detailliertheitsgrad simuliert. Man entwickelt also eine Funktionalitt, die nie gebraucht wird. Es ist schwierig, die richtige Balance zwischen einer sehr pragmatischen Vorgehensweise (ohne Rcksicht auf die reale Welt) und einer detailgetreuen Simulation zu nden. Das ist einer der Grnde, warum die objektorientierte Programmierung so schwierig ist. Man muss viel Erfahrung sammeln, bevor man diese Fallen sicher meistert. 6.4.3 Spezielle Fallen in Java Die meisten der oben beschriebenen Fallen existieren in vielen oder den meisten (objektorientierten) Programmiersprachen. Es gibt aber auch Fallen, die vor allem in Java und Java-hnlichen Sprachen existieren: Klasse oder Interface: Fr den Aufbau von Untertypbeziehungen eignen sich Klassen und Interfaces. Jedoch sind Klassen derart eingeschrnkt, dass eine Klasse nur von einer anderen Klasse abgeleitet sein kann. Dagegen besteht bei Interfaces die Beschrnkung, dass keine Methoden geerbt werden knnen. In der Praxis kann man in
422
einer frhen Entwicklungs-Phase oft kaum entscheiden, ob in einem Fall eine Klasse oder ein Interface besser geeignet ist. Diese Entscheidung ist nicht nur fr Programmieranfnger(innen) schwer zu treen. Auch andere Sprachen wie C# haben dieses Problem. Mit der Unterscheidung zwischen Klassen und Interfaces wollte man Schwierigkeiten umgehen, die zusammen mit Mehrfachvererbung etwa in C++ auftreten. Vor allem beim Programmieren in lteren C++-Versionen wurde Mehrfachvererbung hug falsch eingesetzt. Das wollte man in Java vermeiden. Allerdings wei man inzwischen mehr ber Vererbung von Code und es existieren ausgefeiltere Konzepte dafr als ursprnglich in C++. Diese Konzepte haben noch nicht ihren Weg in Java und hnliche Sprachen gefunden. berladen: Methoden knnen in Java berladen sein, wobei mehrere Methoden desselben Namens mit unterschiedlichen Parametertypen in derselben Klasse existieren. berladen ist gelegentlich ntzlich, weil man nicht knstlich unterschiedliche Namen fr hnliche Funktionalitt nden muss. Aber gerade in Java ist berladen auch gefhrlich, weil man manchmal nur schwer erkennen kann, welche der Methoden mit gleichem Namen aufgerufen wird. Zur Unterscheidung zwischen den Methoden werden die deklarierten Typen der Argumente herangezogen, nicht wie beim Senden von Nachrichten die dynamischen. Daraus ergeben sich oft dizile Unterschiede, die bersehen werden und zu Fehlern fhren. Am besten setzt man berladen nur dort ein, wo Verwechslungen ausgeschlossen sind, etwa wenn jede Methode gleichen Namens eine andere Anzahl an Parametern hat. berladen versus berschreiben: Eine Methode der Unterklasse berschreibt eine Methode der Oberklasse, wenn sie (bis auf den Ergebnistyp) dieselbe Signatur hat. In allen anderen Fllen, in denen wir in der Unterklasse eine Methode einfhren, die gleich heit wie eine Methode aus der Oberklasse, wird die aus der Oberklasse ererbte Methode mit der in der Unterklasse denierten Methode berladen; es existieren also beide Methoden gleichzeitig. So einfach die Regel zur Unterscheidung zwischen berschreiben und berladen klingt, so fehleranfllig ist der Umgang damit in der Praxis. Es passiert leicht, dass man unabsichtlich beispielsweise den Typ eines Parameters ndert oder zwei Parameter vertauscht. Der Compiler akzeptiert Derartiges ohne Warnungen oder Fehlermeldungen. Erst beim
423
6 Vorsicht: Fallen!
Testen kann man ein unerwartetes, eigenartiges Programmverhalten feststellen. Andere Sprachen wie beispielsweise C++ sind hinsichtlich des berladens von Methoden restriktiver, sodass der Compiler in den meisten Fllen, wo unabsichtlich berladen statt berschrieben werden knnte, eine Fehlermeldung gibt. In neueren Java-Versionen kann man beim berschreiben der Methode (direkt vor die Denition der berschreibenden Methode) die Annotation @Override hinschreiben. Dann berprft der Compiler, ob tatschlich eine Methode berschrieben wird, und gibt eine Fehlermeldung aus, wenn das nicht der Fall ist. Die Verwendung von @Override ist sinnvoll und empfehlenswert. Allerdings wirkt diese Technik nur in eine Richtung also wenn eine Methode irrtmlich berladen wird, obwohl sie berschrieben werden soll. Wenn eine Methode unabsichtlich berschrieben wird, obwohl wir berladen erwarten, gibt es keine Fehlermeldung. Integrierte Entwicklungsumgebungen bieten eine andere Lsung: berschriebene Methoden werden farblich anders gekennzeichnet als berladene und fallen gleich beim Schreiben des Codes auf, nicht erst beim bersetzen. Ersetzbarkeit von Arrays: ber Generizitt ist es prinzipiell (das heit, in allen Programmiersprachen) nicht mglich, sogenannte implizite Untertypbeziehungen einzufhren: So ist Liste<B> auch dann kein Untertyp von Liste<A> wenn B ein Untertyp von A ist. Man knnte sonst etwa in ein Objekt vom Typ Liste<B> ein Objekt vom Typ A einfgen, das keine Instanz von B ist. Implizite Untertypbeziehungen sind also nicht sicher, und der Compiler garantiert, dass keine solchen Untertypbeziehungen verwendet werden. Aber in Java (genauso wie in C#) ist ein Array-Typ B[] Untertyp des Array-Typs A[] wenn B ein Untertyp von A ist. Dadurch knnen wir beispielsweise folgenden Programmcode schreiben: B[] bs = ...; A[] as = bs; as[0] = new A();
Das in der dritten Zeile in das Array geschriebene Objekt ist danach auch in bs, obwohl bs nur Objekte vom Typ B enthalten soll, keine vom Typ A. In solchen Fllen wird zur Laufzeit eine Ausnahme geworfen, da der Compiler solche Fehler meist nicht feststellen kann.
424
Untertypbeziehungen zwischen Arrays sind in Java (und C#) also gefhrlich und fehleranfllig. Am besten verzichtet man darauf. Raw-Types: Generizitt ist in Java nachtrglich eingefhrt worden, ohne die Zwischensprache oder die Standardbibliotheken dafr in nennenswerter Weise ndern zu mssen. Leider hlt die dabei entstandene Form der Generizitt einige Fallen bereit. Einerseits werden bestimmte sinnvoll erscheinende Operationen einfach nicht untersttzt, beispielsweise das Erzeugen eines Arrays, das Instanzen eines Typparameters enthlt. Der Compiler kann das Programm nicht bersetzen, wenn wir solche Operationen zu verwenden versuchen. Schwerwiegender ist aber ein anderes Problem: Wenn beispielsweise List<A> ein Typ ist, dann ist auch List ein Typ, ein sogenannter Raw-Type. Bei der Verwendung von Raw-Types sind einige Typberprfungen ausgeschaltet. In speziellen Sonderfllen ist die Verwendung sinnvoll, jedoch immer gefhrlich und fehleranfllig. Ohne auf diese Sonderflle einzugehen sagen wir generell, dass Raw-Types zu vermeiden sind. Meist wollen wir gar keine Raw-Types verwenden, sondern vergessen einfach auf das Hinschreiben der spitzen Klammern. Solche Fehler kann der Compiler leider nicht erkennen. Es werden einfach nur bestimmte berprfungen ausgeschaltet und somit inkorrekte Programme akzeptiert. Daher mssen wir stets darauf achten, spitze Klammern hinzuschreiben. Pakete: Neben Klassen braucht man in Programmen auch grere Strukturierungseinheiten, die in Java Pakete heien. Java hat eine sehr einfache Lsung gewhlt: Alle Klassen, die im selben Verzeichnis stehen, bilden zusammen ein Paket. So berzeugend diese Lsung auf den ersten Blick ausschaut, so problematisch ist sie in der Praxis. Oft mchte man die Verzeichnisstruktur ndern und an neue Gegebenheiten anpassen. Das geht aber nicht, weil man dabei auch das Paket ndern und damit vermutlich zerstren wrde. Fast alle Programmiersprachen haben in dieser Hinsicht bessere Strukturierungsmglichkeiten im Groen anzubieten als Java. Falsche Sicherheit: Java gilt als sichere Programmiersprache, weil sehr viele Fehler bereits vom Compiler erkannt werden und viele weitere zur Laufzeit zum Werfen einer Ausnahme fhren. Es wird sichergestellt, dass Java-Programme keinen Zugri zu Ressourcen bekommen, zu denen sie keinen Zugri haben sollen. Augrund dieser Si-
425
6 Vorsicht: Fallen!
cherheit, die in einigen Bereichen zweifelsfrei gegeben ist, trauen wir Java-Programmen. Leider ist die Sicherheit nicht in allen Bereichen gegeben. Natrlich kann man auch in Java fehlerhafte und bsartige Programme schreiben, und auch ein Java-Interpreter selbst kann fehlerhaft sein. Oft wird die Sicherheit von Java deutlich berschtzt. Das fhrt dazu, dass wir manchmal zu wenig testen und uns einfach auf etwas verlassen, was nicht gegeben ist.
426
die Programmentwicklung oder das entstehende Programm als zu inezient und (hinsichtlich der Entwicklungskosten oder des Ressourcenbedarfs zur Laufzeit) teuer erweist, und die Entwicklung daher abgebrochen wird. Oft wird der Begri eines oensiven Programmierstils als Synonym fr einen schlampigen Programmierstil verwendet und daraus abgeleitet, dass ein defensiver Programmierstil vorzuziehen ist. Diese Sichtweise geht jedoch an der Sache vorbei. In vielen Situationen ist ein oensiver Programmierstil vorteilhaft und hat nichts mit Schlamperei zu tun, sondern mit dem Vermeiden unntiger berprfungen. Auch ein defensiver Programmierstil kann schlampig sein, wenn man alle Bedingungen berprft, die sinnvoll erscheinen. berprft werden sollen nur im Programmablauf ntige Bedingungen, keinesfalls irgendwelche anderen vielleicht sinnvollen. Ein guter Programmierstil wird an allen Stellen defensiv sein, wo dies ntig ist, und an allen anderen Stellen oensiv. In folgenden Fllen wird diesbezglich hug ein falscher Programmierstil eingesetzt: Validierung: Daten, die aus externen Quellen stammen (beispielsweise Benutzereingaben), mssen in jedem Fall berprft werden siehe Abschnitt 5.6.1. Auch bei einem sehr oensiven Programmierstil darf man davon nicht abgehen. Schwierig werden solche Validierungen der Daten vor allem dann, wenn keine klaren Zustndigkeiten dafr bestehen, also beispielsweise bestimmte Eigenschaften eines Wertes an der einen Stelle berprft werden, andere Eigenschaften an einer anderen Stelle. In diesen Fllen ist das Programm bezglich der Validierung schlecht faktorisiert. Es passiert leicht, dass man einerseits auf bestimmte berprfungen vergisst und andererseits (aus Angst) unntige berprfungen macht. Beides ist schlecht, sowohl vergessene als auch unntige berprfungen. Die einzige sinnvolle Lsung besteht in einer derartigen Strukturierung des Programms, dass stets klar ist, wo und von wem die Validierungen durchgefhrt werden. Ein defensiver hat gegenber einem oensiven Programmierstil hinsichtlich solcher Validierungen keine Vorteile, kann jedoch bis zu einem gewissen Grad eine schlechte Faktorisierung verstecken. Die Kombination von schlechter Faktorisierung und defensivem Programmierstil wirkt sich fast immer negativ aus: Es wird viel unntiger Programmcode fr unntige bzw. wiederholte berprfungen geschrieben, diese erhhen den Ressourcenverbrauch, und nicht selten sind eigentlich gleichbedeutende berprfungen an unterschiedlichen Programmstellen nicht miteinander konsistent.
427
6 Vorsicht: Fallen!
Zusicherungen: Kommentare an Programmschnittstellen sind in der Regel als Zusicherungen (z.B. Vor- und Nachbedingungen) zu verstehen. In Zusicherungen formulierte Bedingungen sind unbedingt einzuhalten. Fr die Einhaltung von Vorbedingungen sind Clients (Aufrufer von Methoden und Konstruktoren) zustndig, fr andere Arten von Zusicherungen Server (die ausgefhrten Methoden bzw. Konstruktoren). Das ist durch Design-by-Contract ganz klar geregelt siehe Abschnitt 5.1.2. Trotzdem stellt sich immer wieder die Frage, ob man nicht zustzlich zu den Kommentaren auch berprfungen der Bedingungen machen sollte nur zur Sicherheit. In jedem Softwarevertrag gibt es zwei Vertragspartner. Entsprechend muss man zwei Flle unterscheiden: Der Vertragspartner, der zur Erfllung einer Bedingung verpichtet ist, muss unter allen Umstnden fr die Einhaltung sorgen (als Client fr Vorbedingungen, als Server fr die anderen Zusicherungen). Man muss wissen, dass die Bedingungen erfllt sind. Das geht nur, wenn man die Algorithmen von Anfang an entsprechend auslegt. Mit einer einfachen berprfung kommt man meist nicht aus, da man ja auch im Falle des Scheiterns einer berprfung fr die Einhaltung der Bedingung sorgen muss beinahe ein Widerspruch. Der Unterschied zwischen defensiver und oensiver Programmierung spielt dafr keine Rolle. Der andere Vertragspartner kann sich darauf verlassen, dass die Bedingungen eingehalten sind (der Server auf Vorbedingungen, der Client auf andere Zusicherungen). Dafr sind keine berprfungen ntig. Bei einem defensiven Programmierstil berprft man viele Bedingungen trotzdem nocheinmal, bei einem oensiven Stil verzichtet man darauf. In einem beschrnkten Ausma knnen solche berprfungen zum Aufdecken von Fehlern hilfreich sein. Eine vollstndige berprfung ist dagegen nicht zweckmig. Fr manche Bedingungen wren die berprfungen viel zu aufwendig. Systematisch durchgefhrte unntige berprfungen fhren auch dazu, dass der Vertragspartner seine Verantwortung fr die Einhaltung des Vertragsbestandteils nicht mehr ernst nimmt; schlielich werden die Bedingungen ohnehin nocheinmal berprft. Das kann dazu fhren, dass die Bedingungen entgegen den Erwartungen nur einmal berprft werden, und zwar an einer dafr ungeeigneten Stelle.
428
berprfungen durch assert-Anweisungen schaden aber nicht. Man wei ja, dass assert-Anweisungen nur selten ausgefhrt werden und kann sich nicht auf die berprfung verlassen. Ein Vertragspartner darf sich auf nichts verlassen, was vom anderen nicht zugesichert wird. Daran ndern auch berprfungen nichts. Wiederverwendbarkeit: Wenn man ein Programmstck (etwa eine Methode) konstruiert, hat man dafr meist einen bestimmten Anwendungsfall im Kopf. Die Zusicherungen richten sich nach diesem Anwendungsfall. Es passiert leicht, dass restriktive Bedingungen formuliert werden, die in diesem Anwendungsfall erfllt sind, in anderen vielleicht ebenso sinnvollen Anwendungsfllen aber nicht. Durch solche Bedingungen verhindert man die Wiederverwendung des Programmstcks in einem anderen Kontext. Daher soll man unntige (fr die Ausfhrung nicht gebrauchte) Restriktionen vermeiden. berprfungen in assert-Anweisungen knnen helfen, unntige Bedingungen aufzudecken. Schlielich wird man sich beim Programmieren (genauer: bei der statischen Analyse des Programmstcks) stets fragen, was die assert-Anweisung bewirkt. Falls sie innerhalb des Programmstcks wirkungslos bleibt, kann man die Bedingung genausogut entfernen. Das ist ein Beispiel dafr, wie ein defensiver Stil die Qualitt eines Programms gegenber einem oensiven Stil auf unerwartete Weise verbessern kann. Auf den ersten Blick vermutet man ja gerne das Gegenteil, dass nmlich ein defensiver Stil die Wiederverwendbarkeit durch zustzliche berprfungen einschrnken wrde. Entscheidend ist in diesem Fall aber nicht die Unterscheidung zwischen defensiv und oensiv, sondern vielmehr die Genauigkeit der Analyse des Programmstcks. Ein defensiver Stil fhrt hug zu einer genaueren Analyse als ein oensiver. 6.5.2 Programmierstil und Vertrauen Die Wiederverwendung von Programmteilen ist vor allem eine Vertrauensfrage: Kann ich darauf vertrauen, dass ein fremdes (= nicht von mir geschriebenes) Programmstck die von mir erwartete Qualitt hat? In diesem Zusammenhang ist Qualitt sehr allgemein zu verstehen. Es geht nicht nur um Funktionalitt und Zuverlssigkeit, sondern auch um Stabilitt der Schnittstellen (sodass Ersetzbarkeit gewhrleistet bleibt) und die Qualitt der Wartung (falls irgendwo Mngel auftreten). Man muss nicht nur den
429
6 Vorsicht: Fallen!
Programmteilen vertrauen, sondern vor allem auch den Entwicklern der Programmteile. Letzteres ist ein soziales Problem und mit den Mitteln der Technik kaum in den Gri zu bekommen. Aber Programmcode, als Kommunikationsmedium betrachtet, bietet doch eine Entscheidungsbasis zur Abschtzung der Vertrauenswrdigkeit. Mit etwas Erfahrung erkennt man rasch, wieviel Aufwand in die Entwicklung eines Programmstcks gesteckt wurde. Es ist leicht, sich auf guten, bewhrten und oensichtlich stabilen Code zu verlassen. Andererseits sollte man sich ohnehin nicht auf mangelhaften, instabilen Code verlassen. Beim Programmieren muss man viel tun, um Vertrauen in den Programmcode zu gewinnen. Hier sind einige Aspekte zusammengefasst, die fr das Vertrauen entscheidend sind: Schnittstellen: Die fr die Benutzung eines Programmstcks notwendigen Informationen mssen klar und deutlich bekanntgegeben werden. Auerdem mssen die Schnittstellen einfach und in sich logisch gestaltet sein. In allen anderen Fllen ist das Programmstck fr Personen, die an der Entwicklung nicht direkt beteiligt waren, einfach nicht verwendbar. Die gute Ausgestaltung der Schnittstelle zum Programmstck ist jedoch mit viel Arbeit und hohen Kosten verbunden. Wenn man will, dass das Programmstck auch von anderen verwendet wird, fhrt daran aber kein Weg vorbei. Nicht fr jedes Stckchen Code zahlt sich dieser Aufwand aus. Man muss sich eindeutig dafr oder dagegen entscheiden, ein Programmstck fr die Verwendung durch andere vorzusehen. Wenn man sich dafr entscheidet, muss man auch den ntigen Aufwand hineinstecken. Wenn man sich dagegen entscheidet, darf man nicht erwarten, dass der Code trotzdem von anderen verwendet wird. Im Zweifelsfall wird man sich eher gegen die Verwendung durch andere entscheiden, oft auch deswegen, weil nicht ausreichend viele Ressourcen fr eine gute Ausgestaltung der Schnittstelle zur Verfgung stehen. Verstndlichkeit: Man will natrlich nur Programmstcke ausreichender Qualitt verwenden. Dabei stellt sich aber die Frage, woran man die Qualitt messen kann. Ein einfaches und gleichzeitig objektives Entscheidungskriterium zur Beantwortung der Frage gibt es nicht. Oft kann aber schon ein kurzer Blick in den Programmcode viel verraten: Einfacher, logisch strukturierter und gut lesbarer Code wird positiv auallen, auch wenn man nicht den gesamten Code von vorne bis hinten durchschaut. Kompliziert gestalteter (sogenannter barocker )
430
Code wird negativ auallen. Diese Kriterien sind nicht wirklich objektiv, da ein Grund fr barocken Code auch in der Komplexitt der Aufgabe liegen kann. Mit ausreichend Erfahrung kann man aber abschtzen, wieviel Aufwand betrieben wurde, um barocken Code zu vermeiden. Vor allem erkennt man auch Stellen, an denen die Vermutung besteht, dass der Code absichtlich undurchschaubar gehalten wurde. Bereits einige wenige solche Stellen lassen das Vertrauen in den Code schwinden. Man wei einfach nicht, warum der undurchschaubare Code existiert. Es kann sein, dass beim Korrigieren von Fehlern nicht behutsam vorgegangen und zustzlicher Code zum Umgehen von Fehlern eingebaut wurde, wodurch der Code wahrscheinlich noch sehr fehlerhaft ist, keine klare Lsung einer Teilaufgabe besteht und viel Code fr den Umgang mit in Tests vorgekommenen Einzelfllen eingefgt wurde, der aber im Allgemeinen nicht funktioniert, oder absichtlich irgendetwas versteckt werden soll, vielleicht sogar Code, der dem Anwender Schaden zufgt. Alle diese Mglichkeiten stellen Grnde dar, den Code nicht zu verwenden. Daher sollte man sich sehr darum bemhen, den Anschein von undurchschaubarem Code gar nicht erst aufkommen zu lassen. Vorsichtig sollte man sein, wenn man den Code durch Kommentare verstndlicher machen mchte: Gelegentlich wird der Code gerade durch umfangreiche und wenig aussagekrftige oder sogar oensichtlich falsche Kommentare erst recht undurchschaubar. Entwicklungsprozesse: Nicht nur der Code selbst dient zur Abschtzung der Qualitt, sondern auch die eingesetzten Entwicklungsprozesse. Es muss transparent sein, wie bei der Konstruktion jeden Programmteils vorgegangen wird. Vor allem ist interessant, welche Manahmen zur Qualittskontrolle angewandt werden und auf welche Weise auf das Bekanntwerden von Fehlern reagiert wird. Mit ausreichend Erfahrung kann man erkennen, ob der Programmcode mit den bekannt gemachten Entwicklungsprozessen zusammenpasst. Treten dabei Diskrepanzen auf, wird das Vertrauen sehr rasch schwinden. Wartung: Software lebt nur solange sie gewartet wird. Das gilt auch fr einzelne Programmstcke. Wenn der Anschein entsteht, dass hinter einem Programmstck keine Person oder Personengruppe mehr
431
6 Vorsicht: Fallen!
steht, die das Programmstck mit ausreichend Mitteln am Leben erhlt, wird man nicht mehr darauf vertrauen. Standardkonformitt: Standards sind hug umstritten. Einerseits garantiert die Einhaltung von Standards ein Mindestma an Qualitt, auf das man sich verlassen kann, andererseits hinken Standards der technischen Entwicklung immer hinterher. Trotzdem ist es notwendig, sich an Standards zu halten, wenn sie in dem betrachteten Bereich existieren und anwendbar sind. Wenn man Standards ignoriert, besteht schnell der Verdacht, dass man in diesem Bereich einfach nicht genug Wissen hat, um ein Problem eektiv lsen zu knnen. 6.5.3 Einheitliche Regeln Innerhalb eines Teams ist gegenseitiges Vertrauen das oberste Gebot. Ohne Vertrauen kann keine brauchbare Software entstehen. Misstrauen innerhalb eines Teams fhrt zu Eigenbrtelei und mangelnder Kommunikation im Team, Mehrgleisigkeiten, weil Lsungen mehrfach entwickelt werden, statt bereits fertige Lsungen zu verwenden, viel zustzlichem Code fr eigentlich sinnlose berprfungen, hoher Fehleranflligkeit durch widersprchliche berprfte Bedingungen und inkonsistente Mehrfachlsungen, hohem Ressourcenverbrauch sowohl in der Entwicklung als auch in der Programmausfhrung und schlielich sehr oft zum Scheitern eines Projekts. Aus diesen Grnden muss man viel unternehmen um das gegenseitige Vertrauen zu frdern. Es reicht nicht, nur die soziale Einbindung aller Teammitglieder in das Team zu untersttzen, da das Vertrauen, wie oben beschrieben, zu einem guten Teil auch vom Programmierstil abhngt. Daher ist es wichtig, dass alle Teammitglieder einem einheitlichen gemeinsamen Programmierstil folgen. In kleinen Teams von Leuten, die ber einen lngeren Zeitraum zusammen Programme entwickeln, entsteht von selbst ein gemeinsamer Stil. Dieser Stil hngt hug von der Aufgabenteilung
432
6.6 Mythen
und den speziellen Fhigkeiten einzelner Teammitglieder ab. Daher entwickeln unterschiedliche Teams meist auch unterschiedliche Stile. In groen Teams bzw. Unternehmen wrde sich eine unberschaubare Ansammlung unterschiedlicher Programmierstile ergeben, wenn jede kleine Personengruppe einen eigenen Stil entwickelt. Um dem vorzubeugen werden hug klare Vorgaben gemacht. Beispielsweise wird festgelegt, an welche Stellen welche Art von Kommentaren zu schreiben ist, wie Einrckungen und Klammerungen zu verwenden sind, an welchen Programmstellen wer welche Art von nderungen vornehmen darf und wie die nderungen zu dokumentieren sind, wie beim Entwurf und beim Testen vorzugehen ist, und weitere Punkte knnte man fast endlos auhren. Solche Regeln sollen einen einheitlichen Programmierstil erzwingen und damit dem Team oder Unternehmen diesbezglich eine eigene Identitt verschaen und das gegenseitige Vertrauen frdern. Insofern erfllen die Regeln einen hnlichen Zweck wie einheitliche Kleidung oder ein Firmenlogo. Aber der Zweck der Regeln geht klar darber hinaus. Die Regeln haben sich im Laufe der Zeit aus der Erfahrung entwickelt. Sie zeigen einen Weg vor, wie man typische Probleme rasch erkennen oder gar nicht erst entstehen lassen kann. Ergibt sich eine neue Klasse von Problemen, so sucht man nach einem Ausweg in Form neuer Regeln, welche die negativen Auswirkungen dieser Probleme verhindern sollen. Im Laufe der Zeit ergibt sich ein Regelsystem, auf das man beim Programmieren vertrauen kann. Man vertraut nicht nur dem Regelsystem, sondern auch allen Personen, die sich beim Programmieren an das Regelsystem halten. So ergibt sich mit der Zeit ein schlagkrftiges Team, das auf der Basis des Vertrauens auch komplexe Software auf eziente Weise entwickeln kann.
6.6 Mythen
Die Programmierung sowie Programmiersprachen und Programmierstile sind von einer Unzahl an Mythen umrankt. Es liegt in der Natur von Mythen, dass manche von ihnen sich auch bei eingehender und objektiver Untersuchung als zutreend erweisen, andere wiederum nicht. Hug sind sie jedoch so nebuls, dass eine objektive Untersuchung gar nicht
433
6 Vorsicht: Fallen!
mglich ist. Oft erweist sich ein Mythos als kurzfristige Modeerscheinung, gelegentlich als etwas sehr Dauerhaftes. Manchmal entwickeln sich Mythen hin zu eigenen Ideologien oder beinahe schon religisen Glaubenswahrheiten. Wahrscheinlich kennt jede(r) von uns jemanden, der oder die eine bestimmte Programmiersprache, ein Betriebssystem, eine Marke oder eine Technologie als die oder das einzig Wahre betrachtet. So jemand wird unzhlige Argumente dafr nden und Gegenargumente einfach ignorieren. Eine derartige Ideologisierung kommt der Industrie sehr entgegen und wird auf vielfache Weise gefrdert. In gewisser Weise hngt die Ideologisierung mit einheitlichen Regeln und darauf begrndetem Vertrauen zusammen. Man vertraut auf das, an das man glaubt. Gleichzeitig entwickelt man einen Glauben an das, auf das man vertraut. Ohne Vertrauen ist keine vernnftige Softwareentwicklung mglich, und ganz ohne Glaube an das, was man macht und an die Werkzeuge, die man verwendet, kann man kaum erfolgreich sein. Man muss aber auch realistisch sein. Es ist durchaus angebracht, einem Mythos zu folgen, der im Kern einer objektiven Untersuchung standhlt. Andererseits ist es gefhrlich, einem falschen Mythos zu folgen. Die Schwierigkeit besteht darin, falsche von wahren Mythen und kurzfristige Modetrends von dauerhaften Entwicklungen zu unterscheiden. In Dingen, die man selbst nicht machen kann, verlsst man sich gerne auf andere. Nicht selten folgt man einem Mythos, weil das ein Vorbild, also eine andere Person, auf die man vertraut, auch macht. Leider wei man in der Regel nicht, warum das Vorbild dem Mythos folgt. Vielleicht hat das Vorbild die Sache genau analysiert, vieleicht hat es einen guten Riecher fr knftige Entwicklungen, vielleicht jagt es aus Unwissenheit nur einem anderen Vorbild hinterher und vielleicht gefllt sich das Vorbild in dieser Rolle oder wird sogar dafr bezahlt, einen Mythos unter die Leute zu bringen. Der Wahrheitsgehalt eines Mythos hat jedenfalls wenig mit der Anzahl der Leute zu tun, die einem Mythos folgen. Auch eine groe Anzahl von Leuten kann den Empfehlungen eines Gurus blind Folge leisten, obwohl diese Empfehlungen nicht sinnvoll sind. Es ist wichtig, dass man selbst ein Gespr fr den Wahrheitsgehalt von Mythen entwickelt. Man ist gut beraten, alle Empfehlungen, die man immer wieder bekommt, stets zu hinterfragen. Als Beispiel kann dieses Skriptum dienen. Darin werden unzhlige Tipps und Ratschlge gegeben, wie bestimmte Sprachkonstrukte zu verwenden sind und wie nicht. Statt dem blind Folge zu leisten, sollte man alles hinterfragen. Nur wenn man selbst zur berzeugung gelangt, dass die Begrndungen stichhaltig sind, soll
434
6.6 Mythen
man sich danach richten. Wenn man vom Gegenteil berzeugt ist, wird man sich ohnehin nicht an eine Empfehlung halten. Solange man nicht berzeugt ist, sollte man nach tieferen Begrndungen suchen. Das ist aufwendig. Daher hlt man sich oft an Empfehlungen, ohne sie zu verstehen. Genau aus solch unreektiertem Befolgen und Weitergeben von Empfehlungen entstehen nicht selten Mythen von zweifelhaftem Wahrheitsgehalt. Im Folgenden betrachten wir einige konkrete Mythen beispielhaft. 6.6.1 Paradigmen und Mythen Imperative Programmierung ezient: Imperative Sprachen sind nher an der Hardware als deklarative Sprachen. Es ist allgemein bekannt, dass man in deklarativen Sprachen auf einem deutlich hheren Abstraktionsniveau programmiert und dadurch deutliche Einbusen hinsichtlich Laufzeitezienz in Kauf nehmen muss. Andererseits ist es leichter, auf hherem Abstraktionsniveau zu programmieren. So weit der Mythos. Im Kern steckt einiges an Wahrheit, aber Gre und Wichtigkeit der Unterschiede wird hug stark berschtzt. Imperative Sprachen erlauben zwar ein niedrigeres Abstraktionsniveau, aber meist programmiert man trotzdem auf hohem Abstraktionsniveau, was diesen Unterschied fast aufhebt. Bei imperativen Sprachen kann man sich leicht vorstellen, wie Sprachkonstrukte in die Sprache der Maschine bersetzt werden, bei deklarativen Sprachen ist das schwieriger. Das bedeutet jedoch nicht, dass deklarative Programme schwer bersetzbar sind, sondern nur, dass man sich kaum mit Details der bersetzung auseinandersetzt. Aber es stimmt, dass ein hheres Abstraktionsniveau oft zu weniger ezientem Code fhrt jedoch unabhngig vom Paradigma. Die Ezienz kommt hauptschlich von der Wahl der richtigen Algorithmen unabhngig vom Abstraktionsniveau. Es stimmt auch, dass die Programmierung auf hherem Niveau leichter, also mit weniger Fachwissen machbar ist. Einige Sprachen auf hohem Niveau wurden speziell dafr geschaffen, auch Personen mit wenig Wissen darber das Programmieren zu ermglichen. Programme, die mit wenig Wissen erstellt werden, sind von Natur aus oft inezient und von schlechter Qualitt. Schuld daran ist das mangelnde Wissen, nicht das Abstraktionsniveau. Auf Objekte kommt es an: Heute wird fast nur mehr objektorientiert programmiert. Praktisch alle alten Programmiersprachen sind inzwi-
435
6 Vorsicht: Fallen!
schen in einer objektorientierten Variante verfgbar. Der Grund dafr besteht in den Vorteilen der objektorientierten Programmierung, vor allem hinsichtlich der Wartbarkeit von Programmen. Auch hinter diesem Mythos steckt ein wahrer Kern. Es wird aber gerne bersehen, dass die objektorientierte Programmierung nur in einem bestimmten Bereich anderen Paradigmen gegenber berlegen ist, nmlich fr groe, langlebige Programme. Fr ein schnell erstelltes kleines Hilfsprogramm, das nur einmal zur Ausfhrung kommt, ist die objektorientierte Programmierung denkbar ungeeignet. Man muss viel in einen sauberen objektorientierten Stil investieren um die Vorteile nutzen zu knnen. Bei einem kleinen, nur einmal verwendeten Programm zahlt sich dieser Aufwand niemals aus. Nicht jedes Programm in einer objektorientierten Sprache ist in einem objektorientierten Stil geschrieben. Rein prozedurale Programme in einer objektorientierten Sprache verzichten auf die Vorteile der Objektorientiertheit. Tatschlich wird weit weniger objektorientiert programmiert als die Verwendung und Popularitt von Programmiersprachen vermuten lsst. Im Bereich groer und langlebiger Programme haben sich objektorientierte Stile jedoch klar durchgesetzt. Objektorientiertheit lngst out: Die objektorientierte Programmierung hat die 1990er-Jahre geprgt, ist inzwischen aber veraltet. Heute haben wir bessere und coolere Programmierstile wie etwa . . . Derartiges hrt man immer wieder. Der wichtigste Grund besteht einfach darin, dass man ein bestimmtes Schlagwort (das die Punkte ersetzt) als neuen Programmierstil etablieren mchte. Einige dieser Schlagwrter hatten nur eine kurze Lebensdauer und waren bald veraltet. Andere haben sich lnger gehalten und stellen heute etablierte Begrie dar etwa die aspektorientierte oder komponentenbasierte Programmierung. Wie weit sich z.B. Cloud-Computing durchsetzen wird, werden wir erst sehen. Bei keinem Begri kann man aber sagen, dass die objektorientierte Programmierung dadurch verdrngt worden wre. Ganz im Gegenteil. Die objektorientierte Programmierung bietet die Grundlage, auf der sehr viele neuere Konzepte und Ideen fuen. Sie entwickelt sich stndig weiter und greift neue Ideen auf. Natrlich kann es irgendwann so weit sein, dass der Name einer neuen Idee die objektorientierte Programmierung in den Hintergrund drngt, so wie die objektorientierte Programmierung die prozedurale Programmierung in den Hintergrund gedrngt hat. Das bedeutet
436
6.6 Mythen
aber nicht das Ende der objektorientierten Programmierung, sondern ein Weiterleben in neuer Form unter neuem Namen. Funktionale Programmierung fr Freaks: Die funktionale Programmierung scheidet die Geister. Es gibt einige Spinner, die die funktionale Programmierung extrem gut beherrschen und damit unglaubliche Sachen machen. Aber fr normale Programmierer(innen) ist das nichts. Weil es keine Schleifen gibt und alles mit Rekursion gemacht wird, kann ein funktionales Programm niemals ezient sein. So lautet ein weit verbreiteter Mythos. Abgesehen davon, dass die funktionale Programmierung viele Geister scheidet, ist an diesem Mythos kaum etwas Wahres. Es stimmt schon lange nicht mehr, dass Schleifen prinzipiell ezienter sind als Rekursion weder hinsichtlich der Ausfhrungsgeschwindigkeit noch hinsichtlich der Verstndlichkeit. In einem funktionalen Stil kann man die meisten Algorithmen viel einfacher und krzer ausdrcken als in einem imperativen Stil. So rasch und einfach wie in modernen funktionalen Sprachen kann man in keinem anderen Paradigma einfache Programme entwickeln. Nur hinsichtlich Wartbarkeit groer Programme kommen funktionale Programme nicht ganz an objektorientierte Programme heran. Wegen dieser Vorteile ist die funktionale Programmierung nicht auf Spinner beschrnkt. Man kann auch in einer objektorientierten Sprache einen funktionalen Stil verwenden und damit das Beste aus beiden Welten kombinieren. Es ist kein Zufall, dass wichtige objektorientierte Sprachen in letzter Zeit um Sprachkonstrukte (z.B. Lambda-Ausdrcke) erweitert wurden um die funktionale Programmierung besser zu untersttzen. Allerdings kann man die Kombination der beiden Paradigmen nicht beliebig weit treiben. So ist die referenzielle Transparenz eine funktionale Eigenschaft, die in direktem Widerspruch zu zustandsbehafteten Objekten steht. Auerdem ist die in modernen funktionalen Sprachen verwendete Typinferenz nicht beliebig mit Untertypbeziehungen kombinierbar. Typberprfungen schtzen vor Fehlern: Beim Programmieren passieren Fehler. Typberprfungen durch den Compiler knnen Fehler rasch aufdecken und uns davor schtzen. Daher nehmen wir beim Programmieren einen Mehraufwand in Kauf und deklarieren Typen. Leider ist auch das ein Mythos, der nur zu einem kleinen Teil zutrit. Es stimmt zwar, dass der Compiler eine Klasse von Fehlern
437
6 Vorsicht: Fallen!
garantiert verhindern kann. Aber davon sind nur eher einfache Fehler betroen, die man zu einem guten Teil auch ohne Compiler recht rasch nden knnte. Die Vorteile starker Typisierung liegen eher in einem anderen Bereich: Durch die Festlegung von Typen erhhen wir die Lesbarkeit von Programmen, da Typen eine hnliche Rolle wie Kommentare spielen, aber dahingehend berprft sind, dass sie zusammenpassen. Typen untersttzen auch eine statische Denkweise beim Programmieren. Lesbarkeit und statische Denkweise knnen Fehler verhindern, nicht die Typberprfungen selbst. Wenn man nur an den dynamischen Programmablauf denkt und unleserlichen Code schreibt, helfen Typberprfungen kaum. Zukunft ist dynamisch: Die Deklaration von Typen ist berssig. Man verringert die Sicherheit vor Fehlern, weil man beim Programmieren und Testen weniger Vorsicht walten lsst. Auerdem schrnkt man die Flexibilitt ein. Daher ist es kein Zufall, dass in jngerer Zeit vermehrt dynamische Sprachen entwickelt werden. Die Zeit der stark typisierten Sprachen ist vorbei. Auch dieser Mythos stimmt nicht ganz. Die Deklaration von Typen kann die Wahrscheinlichkeit von Fehlern durch bessere Lesbarkeit und statisches Denken reduzieren. Mit der Vorsicht beim Programmieren hat das wenig zu tun. In allen Programmierstilen kann man Vorsicht walten lassen oder auch nicht. Es stimmt jedoch, dass die Flexibilitt eingeschrnkt wird. Viele Einschrnkungen werden bewusst gemacht um gefhrliche Programmierstile zu verhindern. Andere Einschrnkungen sind eine unerwnschte Konsequenz daraus. Aktuelle Sprachen sind trotz starker Typisierung recht exibel, verlangen aber viel Wissen um die Flexibilitt nutzen zu knnen. Das ist erwnscht, da Flexibilitt ohne das ntige Wissen zu fehleranflligen Programmen fhrt. Es stimmt auch, dass in letzter Zeit vermehrt dynamische Sprachen entwickelt werden. Dynamische Sprachen sind in einigen Bereichen vorteilhaft, beispielsweise beim Zusammenfgen bereits existierender Programme zu neuen, greren Programmen sogenannte Glue-Sprachen. Dieser Anwendungsbereich ist im Wachstum begrien. Daneben besteht ein Gegentrend zu stark typisierten Java-hnlichen Sprachen. Vor einigen Jahren hat man fr vieles, was vorher mit dynamischen Sprachen gemacht wurde, pltzlich Java eingesetzt. Dafr ist Java nicht ideal. Jetzt sucht man wieder nach dynamischen Lsungen fr diese Aufgaben.
438
6.6 Mythen
Technologien sind entscheidend: Es kommt nicht so sehr auf das Paradigma an als darauf, ob fr eine Sprache die bentigten Technologien zur Verfgung stehen. An eine Sprache kann man sich anpassen. Fehlende Technologien kann man aber nur schwer ersetzen. In diesem Mythos steckt viel Wahrheit. Nicht selten ist die Untersttzung einer bestimmten, von vielen Leuten gewnschten Technologie der Grund dafr, dass sich eine Sprache in der Praxis durchsetzt. Beispielsweise ist Ruby-on-Rails, ein Framework fr die eziente Erstellung von Web-Applikationen der wichtigste Grund dafr, dass sich die Programmiersprache Ruby so rasch durchgesetzt hat. Unzhlige mit Java zusammenhngende Technologien fhren dazu, dass Java nur schwer zu ersetzen ist. Andererseits besteht eine groe Gefahr in der Abhngigkeit von bestimmten Technologien. Man muss in der Informatik klar zwischen einer Technik und einer Technologie unterscheiden: Eine Technik ist eine bestimmte Vorgehensweise zur Erreichung eines Ziels (unter Verwendung technischer Hilfsmittel). Beispielsweise garantiert man die Termination einer Schleife, indem man jeder Iteration eine Zahl zuordnet, die mit jeder Iteration strikt kleiner wird, aber nie unter 0 kommen kann. Das ist eine Technik. Techniken kann man erlernen oder ernden, aber nicht kaufen. Eine Technologie ist dagegen ein ins eigene Programm einbindbarer Code oder ein Werkzeug, das bei der Erfllung einer Aufgabe hilft. Man muss zwar auch die Anwendung einer Technologie erlernen, aber man muss zustzlich den Code oder das Werkzeug haben, also meist kuich erwerben. Zur Entwicklung einer Technologie reicht es nicht, nur etwas zu ernden, man muss die Erndung auch in ein Programmstck umsetzen. Die Problematik der Abhngigkeit von einer Technologie besteht darin, dass sie altert. Wenn der Code oder das Werkzeug nicht mehr gewartet wird oder sich so ndert, dass er oder es den Erwartungen nicht mehr entspricht, muss man auf eine andere Technologie umsteigen. Es kann sehr schwer sein, eine andere passende Technologie aufzutreiben. Man soll eine Technologie also nur einsetzen, wenn tiefes Vertrauen in die Technologie besteht und es keine andere einfache Mglichkeit gibt, das Gewnschte zu erreichen. Techniken sind und bleiben dagegen immer einsetzbar. Diese Liste knnte man endlos fortsetzen. Fast alles, was man ber Paradigmen hrt oder liest, muss hinterfragt werden, weil die Aussagen so allgemein sind, dass sie kaum in jedem Fall zutreen werden.
439
6 Vorsicht: Fallen!
6.6.2 Mythen in Java Portabilitt: Java verwendet JVM-Code als Zwischencode um sicherzustellen, dass Java-Anwendungen auf jedem System lauhig sind, das Java untersttzt. Auf diesem Gebiet spielt Java einer Vorreiterrolle. Der Mythos ist zum Teil richtig. Durch Verwendung des Zwischencodes erreicht Java tatschlich einen hohen Grad an Portabilitt, da fr die Ausfhrung neben dem Zwischencode nur ein Java-Interpreter ntig ist. Zwischencode alleine reicht zur Erreichung hoher Portabilitt jedoch nicht aus. Viele Anwendungen verlangen, dass neben dem Java-Interpreter am System auch Klassen-Bibliotheken in einer bestimmten Version vorinstalliert sind. Vor allem auf kleinen Systemen sind nicht immer alle Standard-Bibliotheken in der neuesten Version vorhanden. Dies schrnkt die Portabilitt wieder ein. Zwischencode war schon lange vor Java auf vielen Systemen in Verwendung. Die Vorreiterrolle von Java besteht darin, dass eine ganze Reihe an Manahmen gesetzt wurde, um den JVM-Code ber einen sehr langen Zeitraum stabil und damit portabel zu halten. Sichere Sprache: Java ist eine sichere Sprache. Bei der Entwicklung von Java wurde darauf geachtet, dass bereits der Compiler alles berprft, was berprfbar ist, und zur Laufzeit nocheinmal alles berprft wird, damit auch Fehler des Compilers oder Manipulationen des Zwischencodes keine schwerwiegenden Auswirkungen haben. Daher ist es kaum mglich, ber Java in ein System einzudringen. Es stimmt, dass die Java-Entwickler mehr fr die Sicherheit der Sprache unternommen haben als die Entwickler manch anderer Sprachen und Programmiersysteme. Aber hundertprozentige Sicherheit kann Java nicht garantieren. Auch wenn Fehler des Compilers ausgeschaltet sind, kann man immer noch ber Fehler des Interpreters in ein System eindringen. Zustzliche berprfungen zur Laufzeit erhhen die Ausfhrungszeiten von Programmen. Prinzipiell kann ein JavaInterpreter zwar vieles berprfen, aber einige berprfungen (die unter der Annahme eines korrekten Compilers unntig sind) bleiben aus Ezienzgrnden meist abgeschaltet. Weil man wei, dass man ber Compiler und Interpreter nur einen bestimmten Grad an Sicherheit erreichen kann, setzt man heute fast durchwegs zustzliche Manahmen zur Steigerung der Sicherheit ein. Dazu zhlen beispielsweise Viren-Checker sowie die signierte bertragung von Programmdaten.
440
6.6 Mythen
Bei der Konstruktion von Programmen greift man auf die Untersttzung durch formale Methoden oder Laufzeitberprfungen im Programm zurck, die gelegentlich weit ber das hinausgehen, was von Java normalerweise berprft wird. So kann man beim Programmieren (durch verstrktes Problembewusstsein, etwa durch Validierung aller von auerhalb stammenden Daten) oft mehr an Sicherheit erreichen, als wenn man sich auf die Sicherheit einer Sprache verlsst. Fallen beseitigt: Java hat aus lteren objektorientierten Sprachen wie C++ gelernt und die wichtigsten Fallen, die immer wieder zu Fehlern gefhrt haben, beseitigt. Aus diesem Grund hat man typische Fehlerquellen wie das berladen von Operatoren oder die Mehrfachvererbung weggelassen und eine bessere Untersttzung fr den Umgang mit Ausnahmen eingefhrt. So oder hnlich lauten bliche Werbeaussagen aus der Zeit, in der Java noch jung war. Tatschlich stimmen einige damalige Aussagen aus heutiger Sicht nicht mehr ganz. Beispielsweise ist es richtig, dass Mehrfachvererbung in C++ vor langer Zeit oft ganz falsch verwendet wurde und sich deswegen als Falle erwiesen hat. Aber die Vererbungskonzepte haben sich mittlerweile genauso weiterentwickelt wie das Wissen um den optimalen Einsatz dieser Konzepte. Viele Programmierer wrden heute lieber Mehrfachvererbung in Java haben als die (aus mancher Sicht nicht ganz geglckte) Trennung zwischen Interfaces mit Mehrfachvererbung und Klassen mit Einfachvererbung. berladene Operatoren gibt es in Java ohnehin, beispielsweise + fr die ganzzahlige Addition, die Fliekomma-Addition und die Verkettung von Strings. Aber Programmierer knnen nicht, wie in anderen Sprachen, selbst Operatoren berladen. Das ist gelegentlich ein Vorteil, manchmal aber auch ein Nachteil. Unter der besseren Untersttzung von Ausnahmen versteht man die Notwendigkeit von throwsKlauseln in Methoden-Kpfen sowie die finally-Blcke. Neuere Sprachen, die aus Java gelernt haben, verwenden meist jedoch keine throws-Klauseln mehr, weil sie teilweise zu einem unerwnschten und zweckentfremdeten hugen Einsatz von Ausnahmebehandlungen fhren. Auch einige Details von finally-Blcken haben sich als nicht optimal erwiesen. Das soll nicht als Kritik an Java verstanden werden, sondern spiegelt eine allgemeine Erkenntnis wider: Sprachen entwickeln sich weiter. Etwas, das zu Beginn als groe Neuerung gepriesen wird, stellt sich spter als doch nicht ganz so optimal her-
441
6 Vorsicht: Fallen!
aus. Wer spter kommt, kann von den Vorgngern lernen. Aber ohne groe Neuerungen kann sich keine Sprache langfristig durchsetzen, weil gerade diese Neuerungen den Charakter der Sprache bestimmen, obwohl sie meist nicht so wertvoll sind, wie anfangs vermutet. Internet-Sprache: Java ist die Sprache des Internet und des Web. Eigentlich hat Java nichts mit dem Web zu tun, auer dass das Web und Java etwa zur selben Zeit gro geworden sind. Wegen des zeitlichen Zusammentreens haben die Java-Entwickler versucht, sich an den Trend zum Web anzuheften beispielsweise durch Java-Beans, die aber nie besonders hug eingesetzt wurden. Wesentlich huger wird im Web Java-Script eingesetzt, eine dynamische Sprache, die (abgesehen vom Namen) nichts mit Java zu tun hat. Erst in jngerer Zeit konnte Java im Internet in bedeutenderem Ausma Fu fassen, vor allem durch JSF2, einem Framework-Standard zur Entwicklung grascher Benutzeroberchen fr Webapplikationen. Der Grund fr die Verwendung von Java in diesem Bereich ist die allgemeine Popularitt von Java, nicht irgendeine spezielle Spracheigenschaft. C viel ezienter als Java: Objektorientierte Sprachen sind nie so ezient wie konventionelle imperative Sprachen. C ist die Sprache, wenn es auf Ezienz ankommt, da es kein dynamisches Binden gibt und man sehr nah an der Hardware programmiert. Eventuell kommt noch C++ in Frage, wenn man auf dynamisches Binden verzichtet. Aber Java hat hinsichtlich Ezienz keine Chance. Dieser Mythos wird von vielen Elektrotechnikern, Physikern und teilweise auch technischen Informatikern als unumstliche Wahrheit angesehen. Er ist generell weit verbreitet, obwohl eine wichtige Aussage darin sehr wahrscheinlich nicht zutrit: Dynamisches Binden ist ezienter als beispielsweise eine Mehrfachverzweigung durch eine switch-Anweisung in C oder C++. Mit dynamischem Binden knnen sich also ezientere Programme ergeben als ohne. Programme in C und C++ werden heute meist mit demselben Compiler bersetzt, sodass einander entsprechende Programme auch gleich ezient sind. Es gibt auch zahlreiche Vergleiche hinsichtlich der Ezienz einander hnlicher C++ und Java-Programme. Meist ist dabei C++ tatschlich ezienter, aber nur minimal etwa innerhalb von 20%. Solche kleinen Unterschiede sind kaum nennenswert. Trotzdem steckt hinter dem Mythos ein wahrer Kern: In den Vergleichen wer-
442
6.6 Mythen
den ja nur einander hnliche Programme betrachtet. Typische CProgrammierer legen viel mehr Augenmerk auf die Ezienz als JavaProgrammierer, und das kann sich drastisch auswirken. So brauchen typische Programme in Java und C++, die in einem hnlichen Stil geschrieben sind, oft vierzig Mal so lange als auf Ezienz getrimmte C-Programme, die dieselbe Aufgabe erledigen. Der Unterschied liegt also nicht in den Sprachen und Compilern, sondern im Programmierstil. Wer will knnte auch in Java ezient ausfhrbaren Code schreiben. Bei der Programmierung in Java legt man aber mehr Wert auf die eziente Entwicklung (also das rasche Erstellen des Codes) und die gute Wartbarkeit und verzichtet dabei auf Ausfhrungsezienz. Java ist ein alter Dinosaurier: Java stammt ungefhr aus der Mitte der 1990er-Jahre und hat sich seither nur sehr langsam verndert. Andere Sprachen entwickeln sich wesentlich dynamischer weiter und bieten daher bereits viel fortschrittlichere Konzepte. In naher Zukunft wird Java so veraltet sein, dass es zum Aussterben verurteilt ist. Wie Programme haben auch Programmiersprachen einen bestimmten Lebenszyklus. Bei erfolgreichen Sprachen kommt nach einer eher langsamen Einfhrungsphase ein steiler Aufstieg gefolgt von einem langsamen Abstieg. Java steht eher am Beginn der Abstiegsphase. Wenn sich Java so wie blich entwickelt, dann wird die Sprache noch lange existieren, auch wenn die Bedeutung abnimmt. Die geringe Dynamik lsst sich durch den Erfolg erklren: Aufgrund des intensiven Einsatzes und der groen Anzahl an Java-Programmierer(inne)n wrden starke nderungen einfach nicht akzeptiert werden. Anders verhlt es sich mit nicht ganz so erfolgreichen, aber dennoch gut untersttzten Sprachen: Anpassungen an neue Gegebenheiten mssen rasch erfolgen, damit die Sprache an Bedeutung gewinnen kann, auch wenn dadurch einige etablierte Programmierer(innen) verschreckt werden. An lteren Sprachen (etwa C++ oder Smalltalk) sieht man, wie nach einer lngeren Abschwungphase oft wieder mehr Dynamik in die Entwicklung kommt, die zu einem Wiederaueben fhrt. Das hat eben damit zu tun, dass ein etwas geringerer Erfolg die Dynamik frdert. Vermutlich wird die Dynamik in der Entwicklung von Java irgendwann zunehmen und der Sprache damit ein langes Leben garantieren. Aber irgendwann wird auch Java berholt sein und durch eine andere Sprache ersetzt werden. Welche Art von Sprache das sein knnte, zeichnet sich noch nicht ab.
443
6 Vorsicht: Fallen!
Auch diese Liste kann man beliebig lang fortsetzen. Generell muss man bei Mythen Vorsicht walten lassen. Nicht alles, was einer gngigen Meinung entspricht, trit auch wirklich zu. Hinter fast jedem Mythos steckt ein Krnchen Wahrheit, aber auch viel Dichtung. Wenn man die Wahrheit wissen mchte, muss man viel tiefer blicken und sich sehr eingehend mit einer Frage beschftigen. Rasche Antworten greifen meist zu kurz. Ein Appell am Ende: Man darf nicht alles glauben, was man liest oder hrt, sondern muss sich stets ein eigenes Bild machen und eine eigene Meinung bilden. Das gilt auch im Bereich der Programmierung. Zu viele falsche Propheten verbreiten Meinungen, auf die man besser nicht hren sollte. Sogar Vorbilder, auf die man normalerweise vertrauen kann, irren sich gelegentlich. Das einzige, worauf man sich verlassen sollte, ist die eigene Erfahrung und das eigene Wissen nicht zu verwechseln mit Mythen, die man im Laufe der Zeit aufgesammelt hat. Wenn man einer Sache nicht sicher ist, probiert man sie einfach aus, sofern das mglich ist. Man darf auch diesem Skriptum nicht trauen. Darin sind sicherlich viele Fehler enthalten. Am besten probiert man alle Beispielprogramme aus und versucht sie zu verbessern. Der Stichhaltigkeit der Argumente sollte man sich selbst vergewissern und die Argumente zu widerlegen versuchen. So eignet man sich echtes Wissen an im Gegensatz zu Mythen, die man einfach unreektiert bernimmt und nacherzhlt. Echtes Wissen anzusammeln ist nicht der einfachste Weg. Aber einfachere Wege stellen sich oft als sehr lang heraus und fhren nicht selten am Ziel vorbei.
444
Wozu dient Garbage-Collection? Wie erledigt ein Garbage-Collector seine Aufgabe? Wie kann man beim Programmieren den Garbage-Collector untersttzen? Welche Fallen bestehen bei Garbage-Collection? Wie kann man beim Programmieren die Garbage-Collection beeinussen? Bei einem StackOverflowError kann man den Stack vergrern. Warum hat man damit nur selten Erfolg? Wozu dient die Methode finalize, und warum wird sie nur selten verwendet? Wie verwendet man eine Free-List? Warum verwendet man sie in der Java-Programmierung kaum? Welche Arten von Streams knnen wir in Java unterscheiden? Wozu bentigt auf Streams die Methode flush? Was kann passieren, wenn mehrfach von derselben Datei gelesen bzw. auf dieselbe Datei geschrieben wird? Welche Fehler passieren leicht beim Umgang mit Dateien? Was ist eine Zeichen-Codierung? Wozu dienen Lock-Dateien? Was versteht man unter einer Antwortzeit? Wodurch kann die Antwortzeit strker als erwartet erhht werden? Was bedeutet Busy-Waiting? Was ist schlecht daran? Welche Anstze gibt es, um Schden durch versteckte Aktivitten von Programmen gering zu halten? Welche Fallen lauern beim Rechnen mit ganzen Zahlen? Was ist ein berlauf oder Unterlauf?
445
6 Vorsicht: Fallen!
Wann mssen wir BigInteger statt int oder long einsetzen? Welche Arten von Problemen bei nicht abschtzbar groen Zahlen kann auch BigInteger nicht vermeiden? Warum ist es meist keine gute Idee, ganze Zahlen durch Fliekommazahlen zu ersetzen, wenn der Wertebereich der ganzen Zahlen mglicherweise nicht ausreicht? Wieso ist es auch bei ganzen Zahlen wichtig, klar zwischen equals und == zu unterscheiden? Wofr verwendet man BigDecimal? Welche Schwierigkeiten treten beim Rechnen mit Geldbetrgen auf? Was ist eine Auslschung, und welche Algorithmen und Probleme sind (im Zusammenhang mit Fliekommazahlen) gut bzw. schlecht konditioniert? Wie entsteht POSITIVE_INFINITY, NEGATIVE_INFINITY und NaN? Ist 0.0 dasselbe wie -0.0? Was ergibt 0.0 == -0.0? Warum sollte man Fliekommazahlen weder direkt mittels == noch mittels equals vergleichen? Wie vergleicht man sie richtig? Wie kann Absorption bei Fliekommaberechnungen zu einem Problem werden? Was kann man dagegen tun? Welche Fallen lauern im Umgang mit null? Welche Vorteile bringt eine Reduktion der Verwendung von null auf das unbedingt notwendige Ausma (Beispiel)? Was sind O-by-One-Fehler, und wodurch entstehen sie? Wie kann man O-by-One-Fehler vermeiden oder erkennen? Wie kann man durch Ausnutzen eines schlecht berprften Randbereichs einen Computer angreifen bzw. in ihn eindringen? Was sind Puerberlufe, warum stellen sie eine groe Gefahr dar, und was kann man dagegen tun?
446
Wodurch unterscheidet sich die Parallelitt von der Nebenlugkeit? Welche Unterschiede gibt es zwischen Multiprocessing und Multithreading? Was versteht man unter dem Aufspannen eines Threads? Was ist eine Race-Condition? Wozu braucht man Synchronisation, und wie funktioniert sie? Warum spielen atomare Aktionen bei der nebenlugen Programmierung eine wichtige Rolle? Wozu verwendet man wait und notify in Java? Wodurch knnen sich nebenluge Threads gegenseitig behindern (Liveness-Properties)? Warum sollen synchronisierte Methoden nur kurz laufen? Welches Ziel verfolgt die strukturierte Programmierung? Auf welchen Strukturen beruht die strukturierte Programmierung? Was ist schlecht an Goto, Fall-Through, Break, Continue, Return, Ausnahmen und Dangling-Else? Welche Fallen lauern in der objektorientierten Programmierung? Wie kann man sie vermeiden? Welche Fallen sind typisch fr Java? Worauf muss man dabei achten? Was unterscheidet die defensive von der oensiven Programmierung? In welchen Zusammenhngen ist ein defensiver Programmierstil vorteilhaft, in welchen ein oensiver? Warum sind berprfungen mancher Zusicherungen (bei defensivem Programmierstil) oft schwierig bzw. verzichtbar? Warum kann ohne Vertrauen keine gute Software entstehen? Welche Aspekte sind zur Gewinnung von Vertrauen in Programmcode wichtig? Welche untersttzenden Manahmen kann man treen?
447
6 Vorsicht: Fallen!
Wie lsst Misstrauen im Team Softwareprojekte scheitern? Was sind typische Teamregeln? Wozu dienen sie? Warum ist es notwendig, ein Gespr fr den Wahrheitsgehalt von Mythen zu entwickeln? Zhlen Sie typische Mythen im Bereich der Programmierparadigmen und von Java auf und analysieren Sie deren Wahrheitsgehalt.
448
449
Man kann eine Nachrichtenquelle als stochastischen Prozess (Wahrscheinlichkeitsrechnung) verstehen, bei dem die Auswahl von Zeichen zufllig nach einer bestimmten, den Zeichen des Alphabets zugeordneten Wahrscheinlichkeit erfolgt. Die Auswahl des nchsten Zeichens kann unabhngig von den Vorgngerzeichen erfolgen, oder wie in der natrlichen Sprache von den bereits davor ausgewhlten Zeichen abhngen. Im letzteren Fall mssen beim Modellieren der Nachrichtenquelle Zustandsbergnge bercksichtigt werden (Markov-Kette ). Wissen ber die Wahrscheinlichkeitsverteilung ermglicht die Codierung der Zeichen durch Binrfolgen, sodass codierte Nachrichten im Mittel minimale Lnge haben. 7.1.1 Informationsgehalt als statistische Eigenschaft Ganz zentral in Shannons Informationstheorie ist die Wahrscheinlichkeitsverteilung der Nachrichten bzw. Zeichen. Der Informationsgehalt einer Nachricht ist umso grer je grer die berraschung beim Empfang der Nachricht ist, d.h. je geringer die Wahrscheinlichkeit ihres Auftretens ist. Ein Gedankenspiel verdeutlicht diese Idee: Eine Lerngruppe trit sich regelmig um gemeinsam zu lernen. Der Organisator gibt per SMS bekannt, der Sto welcher Lehrveranstaltung zum nchsten Treen vorbereitet werden soll. Aufgrund eines Fehlers kommt jedoch nur ein einzelnes Zeichen des Textes bei den Empfngern an. Dabei ist entscheidend, ob es ein Zeichen ist, das hug oder selten vorkommt. Ein seltenes Zeichen, wie das Zeichen x, knnte die Lehrveranstaltung bestimmen (z.B. Programmierpraxis). Ist dieses Zeichen hingegen ein e wrde der Empfnger nur wenig Information bekommen, da dieses Zeichen in der deutschen Sprache und in den Namen der Lehrveranstaltungen hug vorkommt. Seltene Zeichen haben also mehr Informationsgehalt. Ist ber die Nachrichtenquelle bekannt, dass diese immer nur dasselbe Zeichen (mit Wahrscheinlichkeit p = 1) sendet, whrend andere Zeichen gar nicht vorkommen (p = 0), hat der Empfang dieses Zeichens einen Informationsgehalt von 0. Information muss also einen Wissenszuwachs bringen. Wir wollen den Informationsgehalt eines gesendeten Zeichens quantizieren. Im Folgenden bezeichnet h(p) den Informationsgehalt eines Zeichens, das mit der Wahrscheinlichkeit p auftritt. Bei kleiner Auftrittswahrscheinlichkeit soll h(p) gro sein, also soll h(p) von 1/p abhngen. Fr den Informationsgehalt einer Folge von Zeichen soll gelten, dass dieser gleich der Summe der Informationsgehalte ihrer einzelnen Zeichen ist, wenn die Zeichen voneinander unabhngig auftreten. Wenn also das
450
Zeichen e mit der Wahrscheinlichkeit 0,17 auftritt und das Zeichen h mit der Wahrscheinlichkeit 0,04, dann ist die Auftrittswahrscheinlichkeit der Zeichenfolge ehe gleich 0,17 0,04 0,17 = 0,0011561. Fr Informationsgehalte von unabhngigen Zeichenfolgen muss also Folgendes gelten (wobei p1 und p2 die Auftrittswahrscheinlichkeiten einzelner Zeichen oder von Zeichenfolgen sind): h(p1 ) + h(p2 ) = h(p1 p2 ) Der Informationsgehalt einer Nachricht ist daher folgendermaen deniert: 1 h(p) = ld = ld(p) p Dabei ist ld der Logarithmus-Dualis (Logarithmus zur Basis 2) und p die Wahrscheinlichkeit des Auftretens der Nachricht. Die Basis 2 wird gewhlt, da dadurch eine binre Unterscheidung von 2 Mglichkeiten (Ja/NeinEntscheidung) mit gleicher Wahrscheinlichkeit p = 0,5 einen Informationsgehalt von 1 hat: ld(1/0,5) = ld(2) = 1. Der Informationsgehalt wird in der Einheit bit gemessen, die eine elementare Entscheidung reprsentiert. Daten mit einem Informationsgehalt von einem bit kann man ber ein Bit darstellen; bit und Bit heien nicht zufllig fast gleich. Jeder Nachrichtenquelle liegt ein stochastischer Prozess zugrunde und umgekehrt kann jeder stochastische Prozess, der eine diskrete Zeichenfolge aus einem Alphabet erzeugt, als Nachrichtenquelle gesehen werden. Es ist dabei unerheblich, ob durch diesen stochastischen Prozess irgendetwas ausgedrckt wird oder nicht. Der Informationsgehalt2 nach Shannon kann unabhngig von der Bedeutung einer Nachricht durch Kenntnis des Alphabets und der seinen Zeichen zugeordneten Auftrittswahrscheinlichkeiten bestimmt werden. Zum Beispiel ist auch ein Wrfelwurf eine Nachrichtenquelle, und auf Basis der Wahrscheinlichkeitsverteilung beim Wrfelwurf kann jedem Wurf ein Informationsgehalt zugeordnet werden.
An diesem Beispiel erkennt man, dass die Annahme der Unabhngigkeit der einzelnen Zeichen in Wrtern einer natrlichen Sprache in der Regel nicht gerechtfertigt ist. Wenn zum Beispiel die ersten beiden Zeichen der Zeichenfolge eh sind, erhht das in der deutschen Sprache die Wahrscheinlichkeit des Auftretens des Zeichens e oder r als nchstes Zeichen der Folge, bzw. verringert die Wahrscheinlichkeit fr ein erneutes Auftreten von h. Diese Abhngigkeiten sollten eigentlich durch eine sogenannte Markov-Kette hherer Ordnung modelliert werden. Wir gehen in unseren Betrachtungen trotzdem davon aus, dass die Zeichen unabhngig voneinander auftreten und lassen diese Abhngigkeiten der Einfachheit halber auer Acht. 2 Der Begri Informationsgehalt wird in der deutschsprachigen Fachliteratur benutzt. Shannon spricht von der Entropie des einzelnen Zeichens.
1
451
Will man beispielsweise eine Person unter sechs Kandidaten durch Werfen eines Wrfels auslosen, so reicht ein einziger Wurf, da ein Wrfel sechs Seiten hat. Verwendet man statt dem Wrfel ein Objekt in Form eines Tetraeders (vier Seiten), so sind zwei Wrfe ntig um die Entscheidung zu treen. Bei einer zweiseitigen Mnze sind es drei Wrfe. Der Informationsgehalt eines Wrfelwurfs ist also hher als der eines Mnzwurfs. Man erhlt erst durch ein Aneinanderreihen von drei Mnzwrfen so viel Information wie durch einen Wurf eines Wrfels. Ein Wrfelwurf hat mehr mgliche Ergebnisse, daher ist die Wahrscheinlichkeit eines bestimmten 1 1 ) als bei einem Mnzwurf ( 2 ). Ergebnisses geringer ( 6 Eine Auswahl muss unter mindestens zwei Mglichkeiten erfolgen. Gbe es nur eine Mglichkeit (z.B. eine Mnze mit zwei gleichen Seiten), wrde diese mit Sicherheit eintreten und die entsprechende Nachricht keine Information enthalten. Sendet eine Nachrichtenquelle immer dasselbe Zeichen (Wahrscheinlichkeit p = 1) so ist der Informationsgehalt h = 0. Andere Zeichen haben eine Auftrittswahrscheinlichkeit von p = 0. Wrde dennoch ein unerwartetes Zeichen auftreten, htte dieses einen unendlich hohen Informationsgehalt. Die Unterscheidung von zwei Mglichkeiten ist die einfachste denkbare Unterscheidung. Diese binre Unterscheidung kann durch ein binre Zier dargestellt werden, die entweder 0 oder 1 ist. Die Einheit bit gibt daher an, wieviele binre Unterscheidungen notwendig sind um den Inhalt einer Nachricht zu bestimmen. Wir knnen genau berechnen, wieviel Information ein Wrfelwurf liefert: ld(6) = 2,58 bit. Im Beispiel muss eine Mnze dreimal geworfen werden um eine Entscheidung unter sechs Mglichkeiten zu treen 0,58 Wrfe gibt es ja nicht. Mit drei Ja/Nein-Fragen kann man eine Zahl zwischen 0 und 7 bestimmen (23 = 8 Zahlen). Daher knnen die Zahlen von 0 bis 7 durch einen Binrcode der Lnge 3 dargestellt werden. 7.1.2 Entropie Die Entropie entspricht der Unsicherheit oder Unbestimmtheit der Nachrichtenquelle. Sie entspricht dem erwarteten Informationsgehalt einer Nachricht, noch bevor diese empfangen wurde. Shannon whlte diesen Begri wegen des hnlichen Begris in der Thermodynamik. Da vor dem Eintreen der Nachricht die Auswahl noch nicht getroffen wurde, mssen die Wahrscheinlichkeiten aller erlaubten Zeichen des Alphabets bercksichtigt werden. Die Entropie H entspricht dem Mittelwert der Informationsgehalte aller erlaubter Zeichen. Dieser Sachverhalt
452
pi h(pi ) =
i
pi ld
1 pi
=
i
pi ld(pi ).
Man sieht, dass bei einem idealen Wrfel die Entropie wieder gleich 2,58 ist, da alle Seiten mit der gleichen Wahrscheinlichkeit auftreten. Daher war es uns oben mglich, den Informationsgehalt zu bestimmen, ohne dabei eine bestimmte Seite des Wrfels anzugeben. Nehmen wir an, dass wir einen gezinkten Wrfel benutzen, wobei beispielsweise die Hugkeiten der Ergebnisse nun wie folgt aussehen: Zahl p 1 0,15 2 0,15 3 0,15 4 0,15 5 0,15 6 0,25
Die Summe der Wahrscheinlichkeiten ergibt wieder 1, da eine der Seiten in jedem Fall auftreten muss. Bei dieser Wahrscheinlichkeitsverteilung ergibt sich die Entropie (gerundet) zu: H = 5 0,15 ld(0,15) 0,25 ld(0,25) = 2,55 bit Die Entropie ist also im Vergleich zum idealen Wrfel (2,58 bit) kleiner, da es einen Wrfelausgang gibt, der wahrscheinlicher ist, als andere. Daher ist die Unsicherheit (die Entropie) geringer. Es lsst sich zeigen, dass die Entropie bei Gleichverteilung der Wahrscheinlichkeiten maximal ist. Gleichverteilung bedeutet maximale Unsicherheit ber die Auswahl. Wrfe von Wrfeln und Mnzen sind idealisierte Nachrichtenquellen mit wohlbekannten Wahrscheinlichkeitsverteilungen. In der Praxis kennen wir die Auftrittswahrscheinlichkeit einzelner Zeichen nicht so genau. Wir knnen die Wahrscheinlichkeiten zwar experimentell bestimmen, aber dabei hngen die gemessenen Werte sehr stark von den in den Experimenten verwendeten Daten ab. Beispielsweise knnen wir die Auftrittswahrscheinlichkeit einzelner Zeichen in Texten bestimmen, indem wir die vorkommenden Zeichen zhlen. Aber Texte in unterschiedlichen Sprachen knnen unterschiedliche Wahrscheinlichkeiten ergeben. Wenn die Sprache bekannt ist, mssten wir eine andere Wahrscheinlichkeitsverteilung annehmen als bei Texten in einer anderen oder unbekannten Sprache. Genauso werden unterschiedliche Autoren und Textarten (Gedicht, Roman, Fachbuch, Computerprogramm, Zeitungsartikel, Mail, SMS, etc.) zu jeweils unterschiedlichen Wahrscheinlichkeitsverteilungen fhren. Wir knnen aufgrund Ihrer
453
Vielzahl nicht alle Kriterien bercksichtigen. Daher ist fast jede Wahrscheinlichkeitsverteilung, von der wir in Berechnungen ausgehen, nur eine mehr oder weniger genaue Annherung an die tatschliche Wahrscheinlichkeitsverteilung. Je mehr wir ber die Eigenschaften der Nachrichten wissen, desto genauer knnen wir an die tatschliche Wahrscheinlichkeitsverteilung herankommen. Zur Berechnung der Entropie mssen wir immer von einer bestimmten Wahrscheinlichkeitsverteilung ausgehen, auch wenn wir die tatschliche Wahrscheinlichkeitsverteilung nicht kennen. 7.1.3 Codierung Eine Codierung oder ein Code ist eine Vorschrift zur Umwandlung einer Darstellung von Daten in eine andere Darstellung. Oft lsst sich eine fr einen bestimmten Zweck optimale Darstellung nden. Beispielsweise sollte bei einem ungestrten bertragungskanal die bertragung mglichst schnell durchgefhrt werden, also die Lnge der Nachrichten im Mittel mglichst klein sein. Bei einem gestrten bertragungskanal mchte man Nachrichten dagegen so darstellen, dass sie vom Empfnger auch dann verstanden werden, wenn einzelne Zeichen verflscht sind; dafr nimmt man etwas grere durchschnittliche Nachrichtenlngen in Kauf. Wenn eine Nachrichtenquelle beispielsweise Nachrichten durch unabhngige und aufeinanderfolgende Auswahl der Zeichen A, B , C und D mit den Wahrscheinlichkeiten 0,5, 0,25, 0,125 und 0,125 erzeugt, so hat diese Nachrichtenquelle eine Entropie von H = (0,5 ld(0,5) + 0,25 ld(0,25) + 2 0,125 ld(0,125)) 7 bit pro Zeichen. = 4 Eine mgliche Codierung als Binrfolge knnte so aussehen: Zeichen Codewort A 0 B 10 C 110 D 111 Die einzelnen Zeichen des Quellalphabets A,B ,C ,D werden auf Worte des Zielalphabets abgebildet. Im obigen Beispiel entspricht das Zielalphabet den binren Codeworten. Obwohl die Codeworte variable Lnge haben,
454
ist dennoch jede codierte Zeichenfolge eindeutig decodierbar. Damit ein Code diese Eigenschaft hat, darf kein Codewort Anfangswort eines anderen Codeworts sein. Man nennt diese Eigenschaft auch Prx-Bedingung. Es gibt Vorschriften zur Konstruktion von Codes, die diese Prx-Eigenschaft haben (z.B. der Human-Code ). Die mittlere Wortlnge L eines Codes ist deniert als die mit den Wahrscheinlichkeiten der Zeichen gewichtete Summe der Lngen der entsprechenden Codeworte, also L = p i li
i
wobei li die Wortlnge des Codes von Zeichen i ist. Die Wortlngen sind im obigen Beispiel so gewhlt, dass diese dem Informationsgehalt der Zeichen 1 1 1+ 1 2+ 8 3+ 1 3 = 7 entsprechen. So ist die mittlere Wortlnge 2 4 8 4 gleich der Entropie der Nachrichtenquelle. Eine der wesentlichen Aussagen Shannons ist, dass die mittlere Wortlnge einer binren Codierung nie krzer als die Entropie der Nachrichtenquelle sein kann. Das bedeutet, dass jede binre Codierung einer gegebenen Nachricht der Lnge l im Schnitt mindestens die Lnge H l Bit haben muss.3 Whlen wir stattdessen folgende Codierung mit konstanter Wortlnge: Zeichen Codewort A 00 B 01 C 10 D 11 Dann ist die mittlere Wortlnge 2 grer als die Entropie. Die Dierenz R = LH wird Redundanz genannt und gibt an um wie viel bit ein Codewort im Mittel lnger ist, als es im optimalen Fall sein msste. Die Redundanz des 7 =1 bit. Dagegen ist die obigen Codes mit konstanter Wortlnge ist 2 4 4 Redundanz der davor betrachteten Prx-Codierung mit variabler Wort7 7 = 0 bit, jener Code hat also die minimale mittlere Wortlnge. lnge 4 4
3
Dies gilt nur wenn die Zeichen einzeln binr codiert sind und die relativen Hugkeiten der Zeichen der Nachricht mit den Wahrscheinlichkeiten der Zeichen des Alphabets bereinstimmen (also eine realittsnahe Wahrscheinlichkeitsverteilung angenommen wurde).
455
Redundanz ist an sich weder gut noch schlecht. In manchen Fllen bentigen wir viel Redundanz um eine Codierung mit bestimmten Eigenschaften auszustatten, in anderen Fllen mchten wir Redundanz weitestgehend vermeiden. Wir werden uns in Abschnitt 7.2 mit einigen Bereichen der Programmierung und Codierung beschftigen, in denen die Redundanz eine wichtige Rolle spielt. 7.1.4 Betrachtungsebenen der Information In Abschnitt 1.4.1 wurden drei Aspekte der Beschreibung natrlicher und formaler Sprachen unterschieden: Syntax, Semantik und Pragmatik. Auch Nachrichten (im Sinne von Shannons Informationstheorie) kann man unter diesen Gesichtspunkten betrachten. Das ist mglich, da jedes Computerprogramm ebenso eine Nachricht in diesem Sinn ist. Es besteht aus einer Folge von Symbolen (Schlsselwrtern, Bezeichnern, Klammern, etc.) und enthlt Informationen auf verschiedenen Strukturebenen. Information entsteht, indem wir die rohen Daten einer Nachricht interpretieren. So ist Information gem dem internationalen Technologiestandard ISO 2382 deniert als die vom Menschen den Daten mittels Vereinbarung ber Ihre Darstellung gegebene Bedeutung, whrend Daten deniert werden als eine interpretierfhige, in einer formalisierten Art und Weise verfgbaren Reprsentation von Informationen, nutzbar zur Kommunikation, Interpretation oder zur Verarbeitung. Rohe Daten sind also ohne die Interpretation durch den Empfnger sinnlos. Die Information steckt nicht direkt in den Daten selbst, sondern in der (menschlichen) Interpretation der Daten. Die unterste Ebene, auf der wir Information betrachten knnen, ist die Codierung der Nachricht. Sie wird im Fall von Programmen durch die Syntax der Programmiersprache bestimmt. Auf dieser Ebene ist eine Nachricht am einfachsten zu erfassen bzw. zu verarbeiten, da deren Bedeutung keinerlei Rolle spielt. So kann man beispielsweise einen Compiler benutzen um eine Codierung in eine andere berzufhren und zu berprfen, ob das Programm syntaktisch korrekt ist. Die Semantik, also die inhaltliche Bedeutung einer Nachricht (bzw. eines Programms) ist dagegen wesentlich komplexer und schwerer zu erfassen. Wir haben bereits in Abschnitt 1.4.1 gesehen, dass ein syntaktisch korrektes Programm auf semantischer Ebene Fehler enthalten kann. Die Semantik wird erst durch Ausfhrung des Programms (etwa durch einen Interpreter) sichtbar, vielleicht auch schon durch das Vorhersehen der Ausfhrung durch Programmierer(innen).
456
Man muss die verschiedenen Informationsbegrie auseinanderhalten. Shannons Informationsbegri ist ein syntaktischer, whrend im alltglichen Gebrauch eher ein semantischer Informationsbegri benutzt wird. Wir beziehen uns meist auf die Bedeutung einer Nachricht und nicht auf deren Entropie. Die Bedeutung entsteht dadurch, dass wir die Symbole einer Nachricht mit Begrien der realen Welt in Beziehung setzen. Man braucht zustzliches Wissen zur Herstellung dieser Beziehung z.B.: Zahl entspricht der Lufttemperatur in Grad Celsius. Diese semantische Ebene wird durch Shannons Informationsbegri nicht erfasst. Syntaktische Information ist durch die Entropie quantizierbar, durch Kenntnis des Alphabets und der Auftrittswahrscheinlichkeiten. Sie steckt dadurch objektiv in der Nachricht und braucht keine Interpretation durch einen Empfnger. Semantische Information bezeichnet dagegen die Bedeutung einer Nachricht fr den Empfnger. Sie entsteht erst durch eine Interpretation der Daten. Sie ist nicht quantizierbar, da verschiedene Interpretationen mglich sind und auf der semantischen Ebene keine einfach objektivierbaren Wahrscheinlichkeiten zugeordnet werden knnen. In der Umgangssprache ist zum Beispiel manchmal von falscher Information oder unverstndlicher Information die Rede. Diese Aussagen beziehen sich auf die Bedeutung einer Nachricht. Falsche Information oder unverstndliche Information gibt es in Shannons Informationstheorie nicht. Bei gutem Willen kann man Shannons Informationstheorie dennoch auf die semantische Ebene bertragen und den Bedeutungen der Nachrichten ungefhre Wahrscheinlichkeiten aus der Sicht des Empfngers zuordnen. Sobald man Wahrscheinlichkeiten zuordnen kann, ist diese Informationstheorie anwendbar. Allerdings muss man sich mit groben Abschtzungen der Wahrscheinlichkeiten begngen, wo objektive Messungen nicht mglich sind. Aus der wohldenierten Informationstheorie mit relativ klar berechenbaren Aussagen wird auf semantischer Ebene ein vager Informationsbegri, der sich der Eindeutigkeit und Berechenbarkeit widersetzt. Unterschiedliche Empfnger derselben Nachricht knnen ja unterschiedliches Wissen und daher unterschiedliche Erwartungen (mit unterschiedlichen Wahrscheinlichkeiten) haben. Was fr einen Empfnger von hohem Informationsgehalt ist, kann fr einen anderen Empfnger nur kleinen Informationsgehalt haben. In vielen Spezialbereichen sind diese Unterschiede zwischen der Mehrheit aller Menschen nicht sehr ausgeprgt, etwa wenn es um die Wahrnehmung von Musik und Bildern geht. Mangels besserer Alternativen greift man in diesen Bereichen trotz aller Mngel auch auf semantischer Ebene hug auf Shannons Informationstheorie zurck.
457
Auch die Ebene der Pragmatik ist zu beachten. Betrachten wir als Beispiel den Satz: Es ist warm. Die semantische Bedeutung des Satzes ist klar, und der Informationsgehalt auf semantischer Ebene hngt davon ab, ob diese Tatsache schon bekannt ist oder nicht. Aber je nach Sprecher (oder Schreiber) und Angesprochenem (oder Leser) kann der Satz auf pragmatischer Ebene ganz unterschiedliche Information bermitteln. In einem Roman wird man damit eine ktive Situation einfhren, sodass der Satz auf jeden Fall neue Information darstellt, unabhngig davon, wie warm es dem Leser gerade ist. Der Kontext ist entscheidend. Zur Anbahnung eines Gesprchs werden solche Stze fter fallen. Dabei ist der Informationsgehalt auf semantischer Ebene nahe bei Null (weil der Angesprochene selbst sprt, wie warm es ist), aber auf pragmatischer Ebene erhlt man damit die Information, dass jemand mit mir in Kontakt treten mchte. Gerade bei der Anbahnung von Gesprchen und im Smalltalk ist der Informationsgehalt der ausgetauschten Nachrichten auf semantischer Ebene fast immer nahe Null. Durch den Austausch von Belanglosigkeiten lernt man einander kennen, was dem Austausch von Information auf pragmatischer Ebene entspricht (z.B. auf Grundlage der Krpersprache, Formulierung der Stze, etc.). In der zwischenmenschlichen Kommunikation ist ein ezienter Austausch semantischer Information oft erst mglich, nachdem ein gewisses Vertrauensverhltnis hergestellt wurde. Information auf pragmatischer Ebene besteht hauptschlich aus MetaInformation, also Information ber den Informationsaustausch. Derartige Information ist auch im Bereich der Technik wesentlich. Beispielsweise fhren Faxgerte nach dem Aufbau einer Verbindung eine Art von Smalltalk um festzustellen, ob auch am anderen Ende ein Faxgert sitzt, welche Protokolle dieses versteht und welche bertragungsgeschwindigkeit mglich ist. Erst danach beginnt die eigentliche Datenbertragung.
458
Man ist dazu verleitet, aus der Denition der Redundanz zu schlieen, dass der redundante Anteil der Daten keinerlei Information enthalten wrde. Das stimmt nur auf jener Betrachtungsebene, auf der die Redundanz ermittelt wurde, also meist auf der syntaktischen Ebene siehe Abschnitt 7.1.4. Auf einer anderen Betrachtungsebene oder unter besonderen Umstnden kann der redundante Anteil durchaus wertvolle Information enthalten. Teile der Information sind im redundanten Anteil vielleicht mehrfach dargestellt, sodass die Information auch bei Verlust eines Teils der Daten erhalten bleibt und die Daten rekonstruiert werden knnen etwa in zyklischen Codes. Allerdings knnen mehrfache Darstellungen Widersprche enthalten, die man vermeiden mchte, z.B. durch eine Normalform fr Daten in einer Datenbank. Mehrfach dargestellte Information kann beispielsweise auch beim Brechen von Verschlsselungen hilfreich sein. Umgekehrt versucht man, durch Vermeidung von Redundanz die Datenmenge klein zu halten und das Knacken von Verschlsselungen zu erschweren. Der redundante Anteil kann genutzt werden um ber die eigentlich darzustellende Information hinausgehende geheime Information in die Daten einzuschleusen. Redundanz ist also keineswegs nur sinnlos. 7.2.1 Datenkompression Unter Datenkompression oder Komprimierung versteht man eine Codierung, mit der man eine Nachricht der Lnge n auf eine Nachricht der Lnge m abbildet, wobei m < n und das Alphabet der ursprnglichen Nachricht nicht grer ist als das der codierten Nachricht. Dadurch erhlt man eine neue Darstellung der gleichen Information in krzerer Form. Man vermindert also die Redundanz. Die Wiederherstellung der ursprnglichen Darstellung aus einer Codierung nennt man allgemein Dekodierung. Im Zusammenhang mit Datenkompression spricht man von auch von Dekompression oder Dekomprimierung. Durch Dekompression kann bei verlustfreier Kompression die ursprngliche Nachricht exakt aus der Codierung wiederhergestellt werden. Bei verlustbehafteter Kompression kann die ursprngliche Nachricht nicht fehlerfrei rekonstruiert werden, jedoch betrit der Verlust im Idealfall nur irrelevante (redundante) Teile der Daten, die im bestimmungsgemen Kontext keine Information beinhalten. Verlustbehaftete Kompression kommt vor allem auf semantischer Ebene zum Einsatz, kaum auf syntaktischer. Beispielsweise sollen Fehler, die durch die Codierung von Audiodaten entstehen, fr normale Personen nicht hrbar sein (semantische Ebene). Ver-
459
lustbehaftete Verfahren erzielen hug viel bessere Kompressionsraten als verlustfreie, d.h., groe Unterschiede zwischen m und n. Das ist mglich, weil in engen Bereichen (wie Audiodaten) auf semantischer Ebene vieles im Normalfall ununterscheidbar ist, was auf syntaktischer Ebene unterschieden wird. In anderen Bereichen, z.B. fr Texte, verwendet man verlustfreie Kompression. Man mchte nach der Dekompression genau denselben Text haben wie vor der Kompression nicht nur einen Text derselben inhaltlichen Bedeutung, aber vielleicht mit anderen Formulierungen. Durch Datenkompression lassen sich Codierungen von Nachrichten der Lnge l erzeugen, deren Lnge in Bits kleiner ist als die durch die Entropie gegebene Minimallnge von H l Bits. Das wird dadurch erreicht, dass die Zeichen nicht einzeln binr codiert werden, sondern adaptiv: Abhngig von der zu codierenden Zeichenfolge werden bestimmte wiederholt auftretende Zeichengruppen zu Superzeichen zusammengefasst. Kommen in einem Programmtext beispielsweise hug acht hintereinander stehende Leerzeichen als Einrckungen vor, dann wird fr alle acht Leerzeichen zusammen nur ein einziges kurzes Codewort verwendet. Auf semantischer Ebene ist eine Einrckung tatschlich nur ein Begri, sodass die Codierung durch ein Codewort recht gut damit bereinstimmt. Auf syntaktischer Ebene gibt es die acht Leerzeichen vielleicht nur deswegen, weil der Editor aus Grnden der Einfachheit keine Tabulator-Zeichen verwendet. Bei der Ermittlung der Entropie ist man von der syntaktischen Ebene ausgegangen ohne die Eigenheiten des Editors zu kennen. Generell sind huge Wiederholungen ein recht verlsslicher Hinweis darauf, dass Daten, auf hherer Ebene betrachtet, viel Redundanz enthalten. Durch die Bercksichtigung von Wiederholungen in der Kompression kann man also bestimmte Formen der Redundanz auf hherer Ebene auch auf syntaktischer Ebene bercksichtigen. Meist stimmen die relativen Hugkeiten einer tatschlich vorliegenden endlichen Zeichenfolge nicht mit den aus vielen Zeichenfolgen ermittelten theoretischen Auftrittswahrscheinlichkeiten berein. Wie in Abschnitt 7.1.2 argumentiert, knnen wir bei der Ermittlung der theoretischen Auftrittswahrscheinlichkeiten niemals alle relevanten Kriterien bercksichtigen. Unterschiedliche Datenmengen haben daher in der Regel unterschiedliche Hugkeitsverteilungen. Aus diesem Grund ist es inezient, wenn zur Kompression unterschiedlicher Datenmengen dieselbe Hugkeitsverteilung angenommen wird, wenn man also eine statische Codetabelle verwendet. Es ist ezienter, wenn man fr jede zu komprimierende Datenmenge eine eigene Hugkeitsverteilung berechnet.
460
Ein Beispiel fr ein verlustfreies Kompressionsverfahren mit hohen Kompressionsraten ist der LZW-Algorithmus (benannt nach dessen Entwicklern Lempel, Ziv und Welch). Bei der Codierung wird die benutzte Codetabelle in jedem Codierungschritt mit einer neuen Zeichengruppe und einem zugeordnetem Codewort erweitert. Falls eine Zeichengruppe wiederholt auftritt, werden die Zeichen nicht mehr einzeln codiert, sondern das entsprechende Codewort benutzt. Auf diese Weise werden Wiederholungen gut erkannt und genutzt. Die Codetabelle muss dabei nicht an den Empfnger bermittelt werden, da sie bei der Dekompression schrittweise aus den komprimierten Daten rekonstruiert werden kann. In der Praxis scheint die durch die Entropie gegebene Minimallnge einer Codierung von geringer Bedeutung zu sein, da sie bei vielen Kompressionsverfahren unterschritten wird. Man muss jedoch bercksichtigen, dass die Beschreibung eines Codierungsverfahrens wie LZW ebenfalls einer Information entspricht, die zwischen Sender und Empfnger vereinbart werden muss und zu den komprimierten Daten dazukommt. Hier bewegen wir uns allerdings wieder auf der semantischen Ebene, wo eine Quantizierung des Informationsgehalts schwierig ist. 7.2.2 Verschlsselung und Information Die Kryptographie ist eine schon recht alte Wissenschaft, die sich mit dem Verschlsseln von Nachrichten beschftigt: Ein klar lesbarer Text (oder anderes Datenmaterial) wird mithilfe eines Verschlsselungsverfahrens in einen unleserlichen Geheimtext umgewandelt. Das Verschlsselungsverfahren verwendet dazu einen Parameter, den man Schlssel nennt. Beim Entschlsseln wird der Geheimtext mithilfe desselben (oder bei manchen Verfahren eines dazupassenden anderen) Schlssels wieder in den ursprnglichen klar lesbaren Text zurckgewandelt. Nur wer den entsprechenden Schlssel kennt, kann den Geheimtext entschlsseln. Vereinfacht dargestellt hnelt eine Verschlsselung einer Codierung, wobei die Codetabelle als Schlssel dient. Allerdings wird durch die Codierung immer nur ein Symbol nach dem anderen in ein Codewort bersetzt, whrend man bei der Verschlsselung aus Sicherheitsgrnden den gesamten Text oder zumindest groe Textblcke als Einheit betrachtet und in einem Schritt bersetzt. Das macht es schwierig, einen unbekannten Schlssel zu erraten oder durch Ausprobieren zu ermitteln. Der Geheimtext enthlt zusammen mit dem Schlssel dieselbe Information wie der klar lesbare Text, sowohl auf syntaktischer als auch semanti-
461
scher Ebene. Geheimtext oder Schlssel alleine enthalten zwar auch syntaktische Information, aber in einem guten Verschlsselungssystem sollten sie keinerlei Information auf semantischer Ebene enthalten. Das heit, wir knnen die Daten im Geheimtext sowie im Schlssel nicht auf solche Weise interpretieren, wie das fr den klar lesbaren Text mglich ist. Fr die richtige Interpretation der Daten braucht es Wissen, das wir nicht haben. Auch bei genauer Kenntnis des Verschlsselungsverfahrens haben wir nicht genug Wissen um den Geheimtext ohne Schlssel interpretieren zu knnen. Erst alle drei Teile Geheimtext, Schlssel und Verschlsselungsverfahren liefern zusammen das ntige Wissen fr die richtige Interpretation. Die Verschlsselung macht eine wesentliche Eigenschaft von Informationen auf semantischer Ebene deutlich: Man braucht Wissen (bzw. Information) um Zugang zur Information in den Daten zu bekommen. Dabei handelt es sich nicht um oberchliches, leicht beschabares Wissen. Man muss schon einen sehr hohen Aufwand betreiben um sich dieses Wissen aus einer Analyse der Daten und der Datenverwendung zu beschaen. Nicht immer entsteht ein Geheimtext durch absichtliche Anwendung eines Verschlsselungsverfahrens. Gar nicht so selten handelt es sich um ganz gewhnliche unverschlsselte Daten. Die semantische Information in diesen Daten ist jedoch zusammen mit der entsprechenden Dokumentation verlorengegangen. Dabei spielt die Dokumentation die Rolle eines Schlssels. Oft werden undokumentierte Daten zusammen mit Hardware oder Softwarepaketen ausgeliefert. Dann ist zwar klar, dass die Daten fr das Funktionieren der Hard- oder Software bentigt werden, aber man kann direkt aus den Daten keine semantische Information herauslesen. Das kann beabsichtigt sein. Fehlende Dokumentation spielt daher eine hnliche Rolle wie die Verschlsselung von Daten. Jede Verschlsselung lsst sich entziern bzw. knacken. Bei guten Verschlsselungen ist dazu ein sehr hoher Aufwand erforderlich. Die Vorgehensweise ist im Wesentlichen dieselbe wie beim Versuch, einen Text in einer unbekannten Sprache mit unbekannten Schriftzeichen zu lesen. Man geht so vor, dass man die semantische Information im Text zu erraten versucht und dann eine Beziehung zwischen den Daten und der erwarteten Information herstellt. Meist wird dies nicht gelingen, ganz selten ist man aber vielleicht doch erfolgreich. Am erfolgversprechendsten ist diese Vorgehensweise, wenn man schon etwas ber die semantische Information in den Daten wei etwa, dass es sich wahrscheinlich um den tglichen Wetterbericht handelt (und Wetterdaten auch ber andere Quellen zu beziehen sind). Je mehr Daten und je mehr Wissen ber die Daten man hat,
462
umso einfacher lsst sich die semantische Information extrahieren. Oft sind statistische Analysen hilfreich. In vielen Wetterberichten und dazu passenden Wetterdaten wird man ein Schema erkennen. Huge Wiederholungen derselben Art an Information liefern einen guten Ansatzpunkt fr statistische Analysen und knnen das Entziern stark vereinfachen. Man kann Information nicht nur verschlsseln sondern auch verstecken. Dabei haben dieselben Daten mehrere unterschiedliche Interpretationen, zumindest eine, die gesehen werden soll, und eine andere, die nicht leicht zu nden ist. So kann man etwa ein Bild oder einen Film auf zahlreiche unterschiedliche Arten (mit vielen verschiedenen Parametern) komprimieren. Wer die dekomprimierten Bilder betrachtet, wird keinerlei Unterschied feststellen. Aber vielleicht ist auch Information, z.B. ein kurzer Text, in der Art der Kompression und ihren Parametern versteckt. Wer nicht gezielt danach sucht, wird diese Information niemals nden. Je mehr Daten insgesamt vorhanden sind, umso mehr Information kann man darin sicher verstecken. Verwendet man verlustbehaftete Formen der Kompression, kann man in derselben Datenmenge mehr Information verstecken, aber die versteckte Information ist auch leichter zu entdecken. Wenn man bedenkt, wie viele Daten heute im Internet entlich verfgbar sind, kann man sich ausmalen, wie einfach es ist, auch groe Mengen an Information relativ sicher zu verstecken und trotzdem einem eingeweihten Kreis leicht zugnglich zu machen. Bestimmte Formen versteckter Information sind heute gang und gbe. Zum Beispiel enthalten Audio- und VideoDateien sehr oft sogenannte Wasserzeichen, das sind versteckte Informationen, ber die man die Quelle von Raubkopien zurckverfolgen kann. 7.2.3 Information in Programmen Computerprogramme enthalten meist eine riesige Menge an (semantischer) Information auf kleinem Raum. Menschen knnen nur eine begrenzte Menge an Information pro Zeiteinheit aufnehmen. Wenn mehr Daten einstrmen als zu bewltigen sind, nehmen Menschen nur einen kleinen Teil der darin enthaltenen Information wahr. Das ist einer der Grnde dafr, warum viele Programme nur schwer zu lesen sind: Die Informationsdichte ist so hoch, dass man vieles bersieht. Dieses Problem versucht man auf verschiedene Weise anzugehen: Erwartungen erfllen: Erwartetes hat einen viel niedrigeren Informationsgehalt als Unerwartetes. Am einfachsten lesbar ist Programmcode, der so gestaltet ist, wie man es sich erwartet. Das bedeutet, dass
463
man so gut es geht allgemein bekannte Algorithmen und Datenstrukturen einsetzt, verstndliche Namen whlt, Anweisungen und Ausdrcke auf bliche Weise formuliert und das Programm einfach hlt. Auf keine Fall sollte man trickreich programmieren und bermig auf Laufzeitezienz achten. Die Erwartung hngt sehr stark mit der Erfahrung zusammen: Nur wenn man viele Programme gesehen hat, wei man, was allgemein blich ist und wie man den Code auch in verzwickten Situationen verstndlich formulieren kann. Struktur und Dokumentation: Man braucht Hinweise um beim Lesen des Programmcodes zu wissen, was man sich erwarten soll. Eine gute Dokumentation (z.B. durch Kommentare) enthlt viel solche Information, ohne jedoch ausschweifend zu sein (also mit mglichst wenig Redundanz). Auf den ersten Blick scheint es, als ob die Dokumentation die Informationsmenge erhhen und so die Problematik weiter verschlimmern wrde. Bei guter Dokumentation ist das nicht der Fall: Sie gibt die entscheidenden Hinweise, worauf man beim Lesen des Codes besonders achten soll und reduziert dadurch die Menge an Information, die man stndig im Kopf behalten muss. Schlechte Dokumentation kann dagegen die Informationsmenge tatschlich unntig erhhen. Neben der Art der in der Dokumentation gegebenen Information ist auch eine klare, einheitliche Struktur der Dokumentation und des Codes von groer Bedeutung. Man muss stets wissen, wo in einem groen Programm man welche Information nden kann. Redundanz und Konsistenz: Jede Form der Dokumentation ist mit Redundanz verbunden. Eigentlich knnte man fast alles, was in einer guten Dokumentation steht, aus einer genauen Programmanalyse ableiten. Aus praktischer Sicht ist das aber sehr schwierig, weil vor allem ein undokumentiertes, den Erwartungen widersprechendes Programm viele Eigenschaften eines verschlsselten Dokuments hat.4 Durch die Redundanz wird die Information im Programm Menschen einfacher zugnglich. Die Sache hat jedoch einen Haken: Wenn der Code nicht mit den durch Programmstruktur und Dokumentation geschrten Erwartungen bereinstimmt, fhrt die Redundanz zu Wi4
Der International Obfuscated C Code Contest ist eine bekannte Veranstaltung, in der es darum geht, C-Programme mit mglichst unerwartetem Verhalten zu nden also genau das Gegenteil zu guten Programmen. Es ist unglaublich, welch kreative, meist sehr kurze und trotzdem praktisch nicht nachvollziehbare Programme dabei entstanden sind. Inzwischen gibt es hnliche Veranstaltungen auch fr andere Programmiersprachen.
464
dersprchen. Das ist sehr irrefhrend und gefhrlich. Eine besondere Form der Redundanz stellen die Typen dar: Zur Beschreibung des Programmablaufs sind sie nicht ntig. Sie enthalten aber eine sehr wertvolle Form der Dokumentation, deren bereinstimmung mit anderen Typen und dem Programmablauf meist schon vom Compiler berprft wird. Widersprchliche Typinformation wird rasch erkannt. Auerdem schrnken uns Typen bei der Gestaltung von Programmen ein. Damit zwingen sie uns, bestimmte Regeln einzuhalten, sodass das Programm eher eine erwartete Struktur bekommt. Visuelle Darstellung: Die meisten Menschen haben eine ausgeprgte visuelle Vorstellungskraft und knnen Information ber Graken und Bilder rascher aufnehmen als ber Texte. Das wird auf verschiedene Weise ausgenutzt. Einfache Beispiele sind Einrckungen im Programmtext, farbliche Hervorhebungen von Schlsselwrtern und Namen und das kontrollierbare Ausblenden von Programmteilen. Es gibt auch zahlreiche visuelle Reprsentationen verschiedenster Aspekte von Programmen wie z.B. Klassendiagramme und Flussdiagramme. Dennoch sind heute fast alle Programme als Texte dargestellt. Man kennt keine einfache visuelle Darstellungsform, die mit allen wichtigen Aspekten gleichzeitig umgehen kann. Es hat auch damit zu tun, dass man die vielen Details in einem Programm visuell doch nicht so rasch berblicken kann wie die Grobstruktur. In heutige Programmiersprachen ist viel Erfahrung aus vergangener Zeit eingeossen. Man hat gelernt, dass lange und an natrliche Sprache angelehnte Texte (wie in Cobol) fr eine rasche Informationsaufnahme nicht so gut geeignet sind als man dachte. Auch sehr kurze und kompakte Texte (wie in APL) erschweren die Informationsaufnahme. Aktuelle Sprachen verwenden meist Beschreibungen mittlerer Kompaktheit mit etwas Redundanz und graschen Elementen wie unterschiedlichen Klammern und Einrckungen. Vieles davon haben wir schon in frheren Kapiteln kennengelernt. Hier sehen wir, dass diese Herangehensweisen einen Sinn haben, der sich direkt aus der Betrachtung der Information auf semantischer Ebene ergibt. So wie man zu verschleiernde Daten in scheinbar harmlosen anderen Daten verstecken kann, so kann man auch schdlichen Programmcode in harmlosem Programmcode verstecken. Typische Beispiele dafr sind Abfragen von Hotkeys, ber die man Zugang zu andernfalls geschtzten Programmteilen bekommt, sowie die Weiterleitung persnlicher Daten wie
465
Kreditkarten- und Konto-Informationen. Durch geschickte Namensgebung und umstndlich formulierten Code sind gut versteckte Programmteile auch durch Code-Reviews nur schwer zu nden. Manchmal wird Code gar nicht absichtlich versteckt, sondern ist durch Ungeschicklichkeit einfach nur schwer nachvollziehbar mit anderem Code verwoben. Man muss unverstndlichen Code immer als sehr schlecht betrachten. Es ist davon auszugehen, dass darin etwas Gefhrliches passiert, egal ob Code absichtlich oder unabsichtlich versteckt wurde.
466
terschiedlich. Die richtige Interpretation kann nicht mittels Typen alleine erfolgen. Hier mssen Programmierer(innen) und Benutzer(innen) die Bedeutung der Daten und etwaigen Zusicherungen darauf kennen. 7.3.1 Zahlen Ganze Zahlen in Java knnen prinzipiell positiv oder negativ sein und werden in Zweierkomplimentdarstellung codiert. Diese Codierung hat gegenber anderen mglichen Codierungen5 den Vorteil, dass man am MSB das Vorzeichen der Zahl erkennt und sich arithmetische Operationen leicht durchfhren lassen. Wir fhren beispielsweise eine Addition so durch: 0110 = 6 1011 = -5 0001 = 1 0110 = 0111 = 1101 = 6 7 -3
Hier wurde die 4-stellige Zweierkompliment-Codierung des Wertebereichs 8 bis 7 aus Abbildung 1.4 benutzt. Man beachte, dass der letzte bertrag, der bei der links dargestellten binren Addition entsteht, nicht bercksichtigt werden muss. Es werden einfach nur die rechten vier Stellen des Ergebnisses 10001 dargestellt. Die rechts dargestellte Addition zeigt, was bei einem berlauf des Wertebereichs passiert: Anstelle des Werts 13 (mit vier Bits nicht darstellbar) kommt 3 heraus. Der Wertebereich ist zyklisch geschlossenen, sodass 7 + 1 gleich 8 und 8 1 gleich 7 ist. Die Darstellung von Fliekommazahlen (float, double) ist komplizierter, da die Postion des Kommas (bzw. des Punkts in angloamerikanischer Schreibweise) variieren kann: Es wird eine Anzahl signikanter Stellen, die man Mantisse nennt, sowie die Position des Kommas durch einen separat angegebenen Exponenten festgelegt. In Binrdarstellung stehen fr Mantisse und Exponent jeweils eine xe Anzahl an Bits zur Verfgung. Betrachten wir beispielsweise folgende Fliekommazahl der Lnge 8 Bit (1 Vorzeichenbit, 3 Exponentenbits und 4 Mantissenbits):
Exponent VZ Mantisse
Das MSB bestimmt das Vorzeichen (0 fr positive und 1 fr negative Zahlen). Danach folgt der Exponent, fr den die Exzessdarstellung benutzt
5
das sind Exzessdarstellung, Einerkomplimentdarstellung oder Benutzung eines dedizierten Vorzeichenbits. Auf die Exzessdarstellung gehen wir weiter unten ein.
467
wird um negative Exponenten zu ermglichen. Bei der Exzessdarstellung wird zum Exponenten ein xer Exzess addiert, in unserem Fall 4. So ergibt sich folgende Codierung der Exponenten: Bitmuster 000 001 010 011 100 101 110 111 ohne Exzess 0 1 2 3 4 5 6 7 mit Exzess 4 4 3 2 1 0 1 2 3
Die Mantisse enthlt das Bitmuster der relevanten Stellen der Zahl. Im Beispiel haben wir dazu 4 Bits reserviert. Man geht davon aus, dass das erste Bit der Mantisse immer 1 ist, sodass dieses nicht gespeichert werden muss. Dahinter steckt die berlegung, dass sich jede Fliekommazahl auf mehrere Arten darstellen lsst. Beispielsweise gilt (in Binrdarstellung): 111.01 25 = 1.1101 23 = 0.0011101 20 . Man whlt nun jene Variante, in der die Zier vor dem Komma gleich 1 oder 1 ist; man nennt sie normalisierte Fliekommazahl. Die gespeicherten Bits der Mantisse enthalten demnach die ersten Stellen der Zahl nach dem Komma. Das obige Bitmuster entspricht daher der Binrzahl 0.0011101, die umgerechnet ins Dezimalsystem 0.2265625 ergibt. Fliekommazahlen in obiger Darstellung lassen sich so miteinander vergleichen, als wren sie mit Vorzeichen und Betrag codierte ganze Zahlen (beispielsweise gilt 01100000 > 00010000). Da Operationen auf ganzen Zahlen einfacher sind als auf Fliekommazahlen, dachte man, dass Vergleichsoperationen so ezienter durchgefhrt werden. Das erklrt die auf den ersten Blick eigenartig erscheinende Darstellung. Es stellt sich nun noch die Frage, wie die Zahl Null dargestellt werden kann, wenn die erste (nicht explizit gespeicherte) Stelle der Mantisse implizit 1 ist. Dazu wird festgelegt, dass der kleinste Wert des Exponenten (in unserem Beispiel 4) eine besondere Rolle spielt: Er bedeutet, dass die Zahl eine denormalisierte Fliekommazahl ist, bei der die erste Stelle der Mantisse gleich 0 ist. Dadurch lassen sich nicht nur die Zahl 0, sondern auch Zahlen mit kleinem Absolutbetrag (in unserem Beispiel im Bereich
468
0.1 24 bis 0.1 24 ) mit entsprechender Genauigkeit (4 signikante Binrstellen) darstellen. Beispielsweise entspricht das Bitmuster 00000001 dem Wert 24 24 = 28 = 0.00390625. Analog dazu werden durch den grtmglichen Exponenten (im obigen Beispiel +3) die Werte Innity (mit Vorzeichenbit 0 und Mantisse 0000) und Innity (mit Vorzeichenbit 1 und Mantisse 0000) und der Sonderwert NaN (mit Mantisse ungleich 0000) dargestellt siehe Abschnitt 6.2.2. Durch diese speziellen Bedeutungen der Exponenten +3 und 4 stehen zur Darstellung von normalisierten Zahlen in unserem Beispiel nur die Exponenten 3 bis +2 zur Verfgung. Da der Wertebereich von Exponenten in realen Fliekomma-Zahlensystemen wesentlich grer ist, spielt der Wegfall des grten und kleinsten Werts jedoch keine groe Rolle. In Java gibt es zwei Grundtypen fr Fliekommazahlen: float und double. Sie folgen dem Schema, das anhand des obigen Beispiels grob umrissen wurde, nur die Bitanzahl fr Exponent und Mantisse sind unterschiedlich: Der Typ float nutzt die Wortlnge von 32 Bit: 1 Vorzeichenbit gefolgt von 8 Bits fr den Exponenten und 23 Bits fr die Mantisse. Der Typ double nutzt 64 Bit: 1 Vorzeichenbit, 11 Bits fr den Exponenten und 52 Bits fr die Mantisse. Damit ergeben sich die in Tabelle 2.5 dargestellten Wertebereiche. Die Fliekommasysteme in Java folgen der Spezikation der Norm IEEE 754 fr Fliekomma-Zahlensysteme mit einfacher (32 Bit, single ) und doppelter (64 Bit, double ) Genauigkeit. Anhand der Fliekommazahlen kann man erahnen, wie schwierig es ohne entsprechende Beschreibung sein kann, die richtige Interpretation eines Bitmusters zu nden. Damit wird der Unterschied zwischen Daten und Information deutlich. Ohne richtige Interpretation sind rohe Daten wertlos. 7.3.2 Zeichen Zeichen werden im Computer in abstrakter Form abgelegt, wobei ber die graphische Darstellung des Zeichens, der sogenannten Glyphe, und andere Merkmale wie die Zeichengre abstrahiert wird. Beispielsweise wird durch die Codierung des Zeichens A nicht festgelegt, ob dieses als A oder A oder A wiedergegeben wird. Ein Text, als eine Folge abstrakter Zeichen, wird oft auch Plain-Text (reiner Text) genannt. Die Reprsentation von Plain-Text soll gerade so viel Information beinhalten, wie zur lesbaren Darstellung des Textes notwendig ist, und nicht mehr. Formatierung, Schriftart und Schriftgre gehren nicht dazu. Andererseits gibt es beispielsweise neben dem lateinischen auch einen kyrillischen Grobuch-
469
staben A und das griechische Alpha. Diese haben zwar einen gemeinsamen Ursprung und auch in manchen Schriftarten gleiche Glyphen, aber unterschiedliche Bedeutung und sind daher in einem Plain-Text auch durch eine getrennte Darstellung zu unterscheiden. Abstrakte Zeichen sind neben Buchstaben, Ziern (09) und anderen (nicht-lateinischen, wie z.B. asiatischen) Schriftzeichen auch Sonderzeichen (z.B., Klammern, Umlaute) und Steuerzeichen. Ein Beispiel fr ein Steuerzeichen ist C, das durch gleichzeitiges Anschlagen der Taste Strg (Steuerung) und der Taste C erzeugt wird. C wird beispielsweise benutzt um in der Konsole ein Programm abzubrechen. Alle Zeichen werden im Computer auf ganzzahlige Werte (bzw. deren Bitfolgen) abgebildet. Beispielsweise ist dem Zeichen A der Wert 65 zugeordnet. In der hexadezimalen Darstellung (siehe Abschnitt 2.2.3) entspricht das der Zahl 0x41. Die Zuordnung von Zeichen und entsprechenden Werten wird durch eine Codierung festgelegt. Ein Beispiel fr eine solche Codierung ist der ASCII-Code (Abkrzung fr American Standard Code for Information Interchange ), der 1963 vorwiegend fr die bertragung von Text ber Telex-Systeme geschaen wurde. Dieser verwendet 7 Bits zur Darstellung von 128 verschiedenen Zeichen (darunter lateinische Buchstaben, Ziern, Sonderzeichen und Steuerzeichen, die zur Steuerung von Ein-/Ausgabegerten, bertragung und Formatierung benutzt wurden). Der Code basiert auf Schriftzeichen des englischen Sprachraums. Jedoch knnen aufgrund des kleinen Wertebereichs nichteinmal alle fr englische Texte notwendigen Zeichen abgebildet werden etwa ergnzende typographische Symbole und Akzentzeichen um Fremdwrter darzustellen. Nach Einfhrung des ASCII-Codes wurden aufgrund der Beschrnkungen viele weitere Standards deniert. Ein Beispiel ist der Zeichensatz ISO 8859-1, auch als Latin-1 bekannt, der 8 Bits nutzt und den ASCIIZeichensatz um 128 Zeichen erweitert. Beispielsweise sind darin auch Umlaute der europischen Sprachen codiert. Oft waren Standards nur regional gltig, teilweise stammten sie von Softwareherstellern. Obwohl alle diese Codierungen denselben ASCII-Code erweiterten und damit abwrtskompatibel waren, fhrte die Vielfalt dazu, dass es zunehmend schwieriger wurde, Texte zwischen Systemen zu bertragen. Weiters war es auch nicht mglich, mit einer einzigen Codierung sowohl europische als auch ideogramm-basierte Schriftsymbole der asiatischen Sprachen innerhalb desselben Texts darzustellen. Daher gab es Bestrebungen, eine einheitliche Codierung zu schaen, die international und auf allen Systemen gelten und alle weltweit verwendeten Schriftzeichen umfassen sollte.
470
Aufgrund der Anforderung, Zeichen aller bekannten Schriftkulturen zu codieren, wurde in den 1990er-Jahren der standardisierte Code ISO 10646 (Unicode) geschaen. Zeichen wurden hier durch eine Bitfolge der Lnge 16 (2 Byte) dargestellt. Damit lassen sich 216 = 65536 verschiedene Werte darstellen. Die ersten 128 Zeichen (0127) entsprechen den Zeichen des ASCII-Codes, wobei jedoch die Formatsteuerzeichen (031) nicht speziziert wurden, sondern stattdessen eine ber den Umfang der ASCIISteuerzeichen weit hinausgehende Menge von neuen Steuerzeichen in einem anderen Wertebereich festgelegt wurde. Da man bald erkannte, dass auch die 65536 Zeichen nicht ausreichen, wurde der Wertebereich mit Unicode 2.0 auf ber eine Million darstellbare Zeichen erweitert. Dabei wurde das ursprngliche Unicode Schema so erweitert, dass im frheren Unicode Schema codierte Texte ohne Umwandlung nach Unicode 2.0 bernommen werden konnten. Unicode deniert die Codierung in einem mehrschichtigen Modell: Auf der obersten Schicht wird jedem abstrakten Zeichen ein eindeutiger Name gegeben (z.B. LATIN CAPITAL LETTER A fr den Grobuchstaben A). In der darunter liegenden Schicht wird jedem Zeichen eine ganze Zahl (z.B. 0x41 fr das Zeichen A) zugeordnet. Diese Zahl wird Code-Point genannt. Der Code-Point ist unabhngig von der Darstellung im Computer deniert, es wird auf dieser Ebene noch kein eindeutiges Bitmuster festgelegt. In der nchsten Schicht wird jeder Code-Point in ein entsprechendes Codewort umgewandelt, das dann zur Reprsentation im Computer benutzt wird. Hier gibt es verschiedene Schemata, die UnicodeTranformation-Formats (UTF), die je nachdem, wie viele Bits im Codewort zu einer Einheit zusammengefasst werden, UTF-8, UTF16 und UTF-32 genannt werden. Tabelle 7.1 zeigt verschiedene UTF-Schemata zur Umwandlung eines Code-Point in eine Binrfolge. Nicht alle Code-Points knnen abgebildet werden. Der Bereich 0xD800 bis 0xDFFF umfasst Surrogate-Code-Points und wird ausgespart, da er die reservierten Prxe (siehe Tabelle) beinhaltet. Codierbare Code-Points (auerhalb dieses Bereichs) werden UnicodeScalar-Values genannt (USV). Die x kennzeichnen die Positionen, die fr
471
7 Information und rohe Daten UTF-8 [Link] [Link] 0xxxxxxx 110xxxxx 10xxxxxx 1110xxxx 10xxxxxx
USV (hexadezimal) 0000 - 007F 0080 - 07FF 0800 - D7FF E000 - FFFF 10000-10FFFF USV 0000 - D7FF E000 - FFFF 10000-10FFFF USV 0000 - D7FF E000-10FFFF
[Link]
[Link]
110111xxxxxxxxxx
00000000000xxxxxxxxxxxxxxxxxxxxx
die Darstellung des USV genutzt werden. Beginnt bei einer UTF-8 Codierung ein Codewort nicht mit dem Bit 0, besteht dieses aus mehr als nur einer Einheit. Beginnt ein Codewort mit 11110, besteht es aus 4 Einheiten zu je 8 Bit, wobei der USV 21 Bits belegt (0x10000 bis 0x10FFFF). Codewrter einer UTF-8-Codierung knnen 8, 16, 24 oder 32 Bit lang sein. Texte in europischen Sprachen, die berwiegend aus Zeichen des Bereichs 0-255 (0x00000x00FF) bestehen, belegen in der UTF-8-Codierung meist nur 8-Bit pro Zeichen. Ein Codewort in UTF-16 kann 16 oder 32 Bit lang sein. ASCII-Texte in UTF-16-Codierung zu speichern wre verschwenderisch, da die ersten 9 Bit immer 0 sind. UTF-16 ist eine angemessene Codierung, wenn die meisten Zeichen in den Bereich oberhalb von 255 (0x00FF) fallen. Sonst eignet sich eher UTF-8. UTF-32 wird wegen der Verschwendung von Bits kaum verwendet. Der Typ char in Java verwendet eine UTF-16-Codierung. Man nimmt an, dass Computer genug Speicher haben, sodass die damit verbundene Speicherverschwendung kein Problem ist. Bei der Ein- und Ausgabe werden Zeichen in beliebige Codierungen umgewandelt (bestimmt durch die Klassen Charset, CharsetEncoder und CharsetDecoder).
472
7.3.3 Abstrakte Werte Wir haben Zeichen als abstrakt bezeichnet, weil Zeichen nicht deren uere Erscheinung beschreiben, sondern die Idee dahinter. Designer entwerfen immer wieder neue Zeichenstze mit neuem, modernerem Aussehen der Zeichen, ohne dabei die Codierung irgendwie zu beeinussen. Auch in einem anderen Sinn sind Werte des Typs char in Java abstrakt: Auf der Ebene des Bytecodes der JVM spielt es keine Rolle, welcher 16-BitWert in der UTF-16-Codierung welchem Zeichen entspricht. Intern enthlt eine Variable vom Typ char nur eine vorzeichenlose Zahl zwischen 0 und 65535.6 Abgesehen von wenigen Klassen und Methoden in den StandardBibliotheken braucht niemand etwas ber Details der Codierung zu wissen. Auf hnliche Weise werden die Wahrheitswerte true und false intern durch die Zahlen Eins und Null codiert. Dazu werden Wahrheitswerte durch int- oder byte-Werte ersetzt, da die JVM fr boolean keine eigenen Befehle hat. Trotzdem ist der Typ boolean in Java nicht mit numerischen Typen kompatibel. Wahrheitswerte lassen sich auch nicht durch Casts in andere Typen berfhren. Der Java-Compiler sorgt also dafr, dass Wahrheitswerte und numerische Werte logisch voneinander getrennt bleiben, obwohl zur Ausfhrungszeit keinerlei Unterschied besteht. In Aufzhlungstypen (siehe Abschnitt 3.2.5) gibt es etwas hnliches: Jeder Aufzhlungstyp deniert eine Menge von Konstanten, die durch jeweils andere ganzzahlige Werte dargestellt werden. Dennoch ist ein Aufzhlungstyp klar von numerischen Typen unterscheidbar, und auch Konstanten unterschiedlicher Aufzhlungstypen stehen in keiner Beziehung zueinander. Selbst wenn man intern eine solche Konstante nur durch eine Zahl darstellt, wei der Compiler stets, zu welchem Aufzhlungstyp die Zahl gehrt. Verwechslungen zwischen Konstanten unterschiedlicher Aufzhlungstypen werden vom Compiler zuverlssig verhindert. In einigen Sprachen verzichtet man auf solche Abstraktionen wie hier beschrieben. Beispielsweise legt man in der Sprache C Zeichen hug in Variablen vom Typ int ab, der C-Typ char entspricht im Wesentlichen dem Java-Typ byte, und Werte des C-Typs char werden bei Bedarf automatisch in solche vom Typ int umgewandelt und umgekehrt. Es wird also nicht zwischen Zahlen und Zeichen unterschieden. Nur fr die
6
Eine Variable vom Typ char kann nur Unicode-Scalar-Values von 0x0 bis 0xD7FF und von 0xE000 bis 0xFFFF darstellen. Fr andere Zeichen bentigt man zwei char-Variablen. Zeichenketten vom Typ String knnen dagegen alle Unicode-Zeichen enthalten, weil mehrere 16-Bit-Werte zu einem Zeichen kombinierbar sind.
473
Ein- und Ausgabe zustndige Funktionen mssen die Codierung der Zeichen kennen. Ebenso gibt es in C keinen eigenen Typ fr Wahrheitswerte, sondern man verwendet dafr Zahlen: Eins steht fr true und Null fr false. Auch in Aufzhlungstypen denierte Konstanten sind in C wie gewhnliche Zahlen verwendbar. Andererseits gibt es auch Sprachen, die besonders viel Wert auf solche Abstraktionen legen. Beispielsweise kann man in der Sprache Ada neue ganzzahlige Typen auf folgende Weise denieren: type Apfel is new Integer; type Birne is new Integer; Werte der Typen Apfel und Birne sind ganze Zahlen, genauso wie Werte des Typs Integer. Auch alle Operationen, die auf Integer-Werten deniert sind, gibt es auf Werten von Apfel und Birne. Aber Werte von Apfel sind nicht mit Werten von Birne oder Integer kompatibel. Wir knnen pfel zu pfeln addieren und Birnen mit Birnen multiplizieren, aber pfel und Birnen sind nicht gemischt verwendbar, genauso wie in Java Zeichen, Zahlen und Wahrheitswerte nicht gemischt verwendbar sind. Die strikte Unterscheidung zwischen pfeln, Birnen und ganzen Zahlen im Ada-Beispiel sowie zwischen Zeichen, Zahlen und Wahrheitswerten in Java kommt hauptschlich von den Namen der entsprechenden Typen, nicht von der Darstellung der Werte. Der Compiler unterscheidet die Werte anhand der Typnamen, die zur Laufzeit oft gar nicht mehr bekannt sind. Man spricht daher auch von nominalen Typen. Im Gegensatz dazu stehen strukturelle Typen, bei denen der Compiler (wie fr elementare Typen in C) nicht die Namen der Typen, sondern die Darstellung der Werte als entscheidendes Kriterium fr die Kompatibilitt von Werten betrachtet. Nominale Typen ermglichen eine Form der Abstraktion, die es bei strukturellen Typen nicht gibt. Nominale Typen spiegeln Unterschiede in der semantischen Information der Werte wider, die im Programm nicht deniert sind. Auf der syntaktischen Ebene der Information sind diese Unterschiede nicht erkennbar. Strukturelle Typen spiegeln dagegen nur Unterschiede auf der syntaktischen Ebene der Information wider. Wie wir in Kapitel 3 gesehen haben, ist die Datenabstraktion in der objektorientierten Programmierung sehr wichtig. Nur durch nominale Typen ist es mglich, die Datenabstraktion auf die Ebene von Typen zu bringen und die Kompatibilitt von Datenabstraktionen vom Compiler berprfen zu lassen. Daher verwenden auch Sprachen wie C++ nominale Typen fr Objekte (aber strukturelle Typen fr primitive Werte). In Java, C#, Ada,
474
etc. werden ohnehin fast berall nominale Typen eingesetzt. Daneben gibt es aber auch Sprachen wie ObjectiveC und Go, die zwar Typberprfungen durch den Compiler untersttzen, aber fr Objekte nur strukturelle Typen verwenden und damit bewusst auf die berprfung der Kompatibilitt von Datenabstraktionen durch den Compiler verzichten. Dynamische objektorientierte Sprachen wie Smalltalk, Ruby und Python verzichten generell auf Typberprfungen durch den Compiler.
475
Implementierungen auch fr Referenzvariablen) werden je zwei aufeinanderfolgende Wrter reserviert. Position 0 eines Frames enthlt im Fall von Objektmethoden die this-Referenz. Jeder Stack-Frame enthlt einen kleinen Operandenstack xer Gre. Operationen innerhalb der Methode verwenden diesen Stack als Zwischenspeicher fr Operanden und Ergebnisse. Die meisten JVMBefehle holen die bentigten Operanden durch pop vom Operandenstack und legen das Ergebnis durch push wieder darauf ab. Der Stack wird auch fr die bergabe von Parametern und Rckgabe von Ergebnissen benutzt. Beim Methodenaufruf werden die aktuellen Parameter (bei Referenztypen Referenzen darauf) in den neuen Stack-Frame kopiert. Wird eine Methode beendet, wird der StackFrame entfernt und der Rckgabewert (falls vorhanden) auf dem Operandenstack im Stack-Frame der aufrufenden Methode abgelegt. Der Heap (zu deutsch Halde) ist ein meist recht groer Speicherbereich, der (anders als der Stack) im Normalfall keine Maximalgre hat, sondern nach Bedarf wachsen kann. Im Heap werden alle Objekte abgelegt, also alles, was mit dem new-Operator erzeugt wird, darunter auch Strings und Arrays. Die Anwendung dieses Operators liefert eine Referenz auf das im Heap neu angelegte Objekt zurck. Die genaue Adresse des Objekts wird von der JVM bestimmt und ist vom Programmierer nicht beeinussbar. Objekte am Heap bleiben auch nach Beendigung einer Methode und dem Entfernen des entsprechenden Stack-Frames erhalten und werden nicht explizit gelscht. Stattdessen wird der entsprechende Speicherbereich vom Garbage-Collector wieder freigegeben, wenn keine Referenz mehr auf das Objekt verweist siehe Abschnitt 6.1.1. Die Method-Area ist ein (abhngig von der Implementierung der JVM oftmals innerhalb des Heap angelegter) Speicherbereich fr alle statischen Daten, das sind also alle Variablen, die pro Klasse existieren (alle static deklarierten Variablen). Die Lebensdauer dieser Variablen beginnt mit dem Laden der Klasse und endet, wenn die Klasse nicht mehr bentigt wird. Beim Laden der Klasse wird die gesamte statische Information einer Klasse, d.h. der JVM-Code einer Klasse inklusive Methoden und einer Tabelle, ber die diese gefunden werden knnen (die Methodentabelle, siehe unten), in der Method-Area gespeichert. Erst dann ist der Code einer Klasse ausfhrbar.
476
Tatschlich ist die Verwaltung der Speicherbereiche in realen Implementierungen der JVM wesentlich komplexer als hier dargestellt. Die Komplexitt kommt hauptschlich von Optimierungen. Beispielsweise werden Programmteile oft zur Laufzeit durch einen JIT-Compiler in Maschinencode bersetzt, sodass nicht mehr alle Operanden und Zwischenergebnisse auf den Operandenstack gelegt werden mssen. Einige neuere JVMImplementierungen knnen Objekte am Stack anlegen, wenn sie nach Beendigung der Methode garantiert nicht mehr zugreifbar sind. Oft ist auch der Heap in kleinere Bereiche unterteilt um einerseits den GarbageCollector besser zu untersttzen und andererseits den Aufwand fr die Synchronisation bei Objektzugrien zu vermeiden, wenn Objekte nur innerhalb eines Threads zugreifbar sind. Als Programmierer(in) braucht man diese Details im Normalfall glcklicherweise nicht kennen. Sogar wenn man sie kennt, sollte man von diesem Wissen normalerweise keinen Gebrauch machen, weil sich solche Details im Laufe der Zeit ndern knnen. 7.4.2 Interne Darstellung von Objekten und Klassen Objekte und Klassen knnen als Datenblcke gesehen werden, die mehrere Felder (Variablen gleicher Gre) enthalten. Felder fr Variablen werden intern nicht mehr durch einen Variablennamen angesprochen, sondern ber einen Oset, das ist die Anzahl der Wrter von der Adresse des Blockanfangs bis zur Variablen. Wenn man den Datenblock als Array von Wrtern betrachtet, entspricht der Oset dem Index der Variablen im Array. Der Oset ist zur Compilezeit bekannt und wird im JVM-Code anstelle von Variablennamen verwendet. Die bersetzung von Variablennamen in Osets nennt man Ausen von Variablen durch den Compiler. Die Spezikation der JVM legt nicht im Detail fest, wie die interne Darstellung von Objekten und Objektreferenzen aussieht. So knnen in verschiedenen Implementierungen der JVM gleiche Objekte im Heap unterschiedlich dargestellt werden. Das Objekt-Layout, beispielsweise die Reihenfolge der Variablen im Objekt-Datenblock und die Indizierung der Methoden in der Methodentabelle (siehe Abschnitt 7.4.3) ist vom Compiler abhngig. Eine Objektreferenz hat meist eine Lnge von 4 oder 8 Bytes. Die JVM-interne Darstellung ist nach auen nicht sichtbar. Das erste Feld eines Objekt-Datenblocks enthlt eine Referenz auf die Klasse, die das Objekt erzeugt hat. Mittels getClass() kann man diese Referenz direkt abfragen. Hauptschlich greift die JVM ber diese Referenz auf die Methodentabelle der Klasse und somit indirekt auf die Metho-
477
den des Objekts zu (siehe Abschnitt 7.4.3). Die weiteren Felder sind fr die Objektvariablen vorgesehen und speichern entweder die Daten primitiver Werte oder Referenzen auf weitere Objekte. Auf diese Weise entstehen im Heap Verkettungen von Objekt-Datenblcken, die auch zyklisch geschlossen sein knnen, d.h., ein Objekt verweist ber eine Kette weiterer Objekte auf sich selbst. Dies kann bei Objekten direkt oder indirekt rekursiver Typen vorkommen z.B. A hat eine Objektvariable vom Typ B und B eine vom Typ A. Derartige Zyklen mssen vom Garbage-Collector und bei der Serialisierung (siehe unten) bercksichtigt werden. 7.4.3 Dynamisches Binden mittels Methodentabelle Der Code einer Methode steht als Teil ihrer Klasse in der Method-Area. Beim Methodenaufruf wird ein neuer Stack-Frame angelegt, darin der Inhalt des Programmzhlers als Rcksprungadresse abgelegt und dem Programmzhler die Adresse der ersten Anweisung der Methode zugewiesen. Die aktuellen Parameter werden, wie oben beschrieben, ber den Stack an die aufgerufene Methode weitergegeben. In prozeduralen und funktionalen Sprachen kennt der Compiler die Adresse jeder aufgerufenen Prozedur oder Funktion. Im compilierten Programm steht daher in jedem Aufruf eine xe Sprungadresse (also die Anfangsadresse, auf die beim Aufruf gesprungen wird). Das nennt man statisches Binden. Auch in objektorientierten Sprachen kann der Compiler oft statisches Binden verwenden, etwa fr statische und private Methoden. Wie in Abschnitt 3.3.2 ausgefhrt ist aufgrund von UntertypPolymorphismus statisches Binden nicht in jedem Fall durchfhrbar. Dann muss man auf dynamisches Binden zurckgreifen. Dabei muss die Sprungadresse zur Laufzeit aus der Klasse des Empfngers einer Nachricht ermittelt werden. Um die zu einer Nachricht gehrende Methode leicht nden zu knnen, wird fr jede Klasse zur Compilezeit eine Methodentabelle angelegt. Sie heit auch Virtual-Function-Table (VFT) oder vtable. Diese Bezeichnungen stammen aus C++, wo dynamisch bindbare Methoden virtuelle Funktionen genannt werden. Die VFT ist ein Array, das die Anfangsadressen aller dynamisch bindbaren Methoden der Klasse enthlt. Nehmen wird an, dass obj eine Variable des deklarierten Referenztyps Typ ist. Dann wird ein Aufruf [Link](...) zu etwas aufgelst, was sich in Java-hnlichem Pseudocode etwa so ausdrcken lsst: [Link]().VFT[indexOf(Typ, msg)](...).
478
Dabei ist indexOf eine von Typ abhngige Funktion, die einen Methodennamen7 in einen eindeutigen Index innerhalb der VFT bersetzt. Diese Funktion ist dem Compiler bekannt (aber nicht direkt von Programmierer(inne)n aufrufbar), sodass im bersetzen Code schon das Ergebnis des Aufrufs von indexOf(Typ, msg) als Konstante i steht: [Link]().VFT[i](...) Das geht, weil [Link]().VFT am Index i die Anfangsadresse von msg enthlt. Da Typ der deklarierte Typ und [Link]() der dynamische Typ von obj ist, muss [Link]() zwangslug ein Untertyp von Typ sein. Whrend i nur vom deklarierten Typ (und der Nachricht) abhngt, hngt die dynamisch ermittelte Anfangsadresse auch vom dynamischen Typ ab. Wird eine Klasse aus einer anderen Klasse abgeleitet, wird im Datenblock der abgeleiteten Klasse eine Kopie der VFT der Basisklasse angelegt. Diese VFT wird um neue Eintrge fr die zustzlichen Methoden der abgeleiteten Klasse erweitert, die in der Basisklasse nicht vorkommen. Wird die Methode mit dem Index i (entsprechend der Funktion indexOf) berschrieben, wird die Adresse im entsprechenden Feld i durch jene der neuen Methode ersetzt daher der Begri berschreiben. Fr nicht berschriebene Methoden bleibt der Eintrag unverndert. Ist U die abgeleitete Klasse und T die Basisklasse, so muss indexOf(U, m) = indexOf(T, m) fr alle Nachrichten m gelten, die in T vorkommen. Fr die normale Ausfhrung von Java-Programmen werden nur die Osets bzw. Indexe bentigt, nicht die Namen von Variablen und Methoden. Dennoch enthalten Klassen in JVM-Code die ursprnglichen Namen. Im sogenannten Konstantenpool sind die Namen als Unicode-Zeichenketten (UTF-8) mit den entsprechenden Osets verknpft. Durch bestimmte Standard-Klassen und Methoden kann man ber Namen, die erst zur Laufzeit ermittelt werden, unter Zuhilfenahme des Konstantenpools auf Variablen zugreifen und Methoden aufrufen. Entsprechende Programmiertechniken nennt man Meta-Programmierung oder reexive Programmierung. Da es sich dabei um fortgeschrittenere Programmiertechniken handelt, die bei falscher Anwendung ein groes Gefahrenpotential in sich bergen, gehen wir in diesem Skriptum nicht nher darauf ein.
7
Bei berladenen Methoden hngt der Index nicht nur vom Methodennamen, sondern auch von der Parameterzahl und -art ab. Zur Vereinfachung wird dieses Detail hier ignoriert.
479
480
sicht eines Objekts, also fr die Datenabstraktion. Die Implementierung eines Objekts erfordert ja ganz anderes Wissen als dessen Verwendung. Daher hat man beim Implementieren ganz andere Information als bei der Objektverwendung. Oft nimmt man an, dass in der Innenansicht einfach nur mehr Wissen (ber Implementierungsdetails) besteht als in der Auenansicht. Tatschlich ist die Situation viel komplizierter. In der Auenansicht hat man hug viel Wissen ber gnstige Verwendungen eines Objekts, die man whrend der Implementierung nicht hat. Daher ist das Wissen in der Auenansicht anders als in der Innenansicht, ohne dass diese beiden Arten des Wissens in einer oensichtlichen Beziehung zueinander stehen mssen. Beispielsweise knnte das Wissen in der Auenansicht fr kartesische Koordinaten sprechen, das Wissen in der Innenansicht aber fr Polarkoordinaten. Eine solche Trennung kann also sinnvoll sein. Sowohl die Innen- als auch die Auenansicht muss so beschrieben sein, dass eine groe Mehrheit an Personen dieselben Informationen aus der jeweiligen Sicht herauslesen. Das ist nur mglich, wenn man stets klar unterscheidet, welches Wissen sich auf welche Sicht bezieht. Objektorientierte Sprachen untersttzen uns bei dieser Trennung: Sie geben schon in der Syntax vor, an welchen Stellen man welche Art an Wissen vermittelt. 7.5.2 Untertypbeziehungen In frheren Kapiteln haben wir gesehen, dass ein Untertyp U eine spezielle Variante des Obertyps T darstellt, U also eine Spezialisierung von T ist. So kann in dem in Abschnitt 3.3.3 gezeigten Beispiel der Typ Beurteilung fr viele unterschiedliche Beurteilungen stehen, whrend die Untertypen Teilgenommen und NichtTeilgenommen spezieller sind. Noch spezieller sind die Untertypen von Teilgenommen, die den einzelnen Noten entsprechen (z.B. B3). Es wre nun mglich, auf Basis von Beurteilungen einer bestimmten Menge von Prfungen eine Wahrscheinlichkeitsverteilung fr das Auftreten der verschiedenen Beurteilungen zu bestimmen. Dabei msste die Auftrittswahrscheinlichkeit des Obertyps immer als Summe der Auftrittswahrscheinlichkeiten seiner direkten Untertypen berechnet werden. Daraus ergibt sich, dass Untertypen einen hheren Informationsgehalt haben als Obertypen, so wie das Wissen ber das Auftreten des Zeichens x mehr Information bringt, als das Wissen darber, dass ein Buchstabe oder eine Zier aufgetreten ist. Untertypen beschreiben Daten genauer als Obertypen und verfgen dazu im Allgemeinen ber zustzliche Objektvariablen und Methoden bzw.
481
berschreiben Methoden des Obertyps so, dass das Verhalten auf genauere Weise speziziert wird. Durch die Przisierung einer Beschreibung im Untertyp erhht sich der Informationsgehalt. Dabei mssen fr die Interpretation der Daten des Untertyps die gleichen Voraussetzungen gelten wie fr die Interpretation der Daten des Obertyps. Beispielsweise sind die in Abschnitt 7.4.3 beschriebenen Eintrge in die VFT eines Untertyps mit denen des Obertyps vertrglich; der Index bleibt beim berschreiben gleich. Zusicherungen des Obertyps mssen auch fr den Untertyp gelten, sonst wre das Ersetzbarkeitsprinzip verletzt und Typberprfungen knnten keine Kompatibilitt zwischen Interpretationen garantieren. Wenn wir uns an die Graphik aus Abschnitt 3.5.3 erinnern, sehen wir, dass durch Abstraktion und das Ersetzbarkeitsprinzip die Informationsmenge auf der statischen Ebene reduziert wird, da hier die abstrakten Schnittstellen (Obertypen) genutzt werden. Auf dieser Ebene wird nicht mehr an Information vorausgesetzt als ntig, sodass die Flexibilitt erhalten bleibt. Der grere dynamische Informationsgehalt wird so gewissermaen auf die statische Ebene gehoben und damit leichter handhabbar. Diese berlegungen gelten fr die Auenansicht eines Objekts. Untertypbeziehungen auf Basis der Ersetzbarkeit beruhen ja ausschlielich auf der Auenansicht. Die Innenansicht braucht darauf keine Rcksicht zu nehmen. Daher ist es mglich, dass die Implementierung eines Untertyps (die Innenansicht) sich gnzlich von der des Obertyps unterscheidet, ohne die Ersetzbarkeit zu verletzen. Die Auenansicht darf dagegen zwar spezieller werden, sich aber nicht prinzipiell ndern.
482
Folge primitiver Werte. Natrlich mssen auch Daten dargestellt werden, die normalerweise nicht nach auen sichtbar sind. In gewisser Weise wird die Innenansicht des Objekts durch standardisierte Daten ersetzt, die aber (ohne Kenntnis der bersetzung von der internen in die externe Darstellung) nicht denselben Informationsgehalt haben wie das Objekt selbst. 7.6.1 Serialisierung Unter Serialisierung versteht man das Umwandeln der internen Objektdarstellung in eine externe Darstellungsform, in der die Daten sequentiell angeordnet sind, sodass sie als Datenstrom gelesen werden knnen. Die externe Darstellung enthlt die gesamte Information, die man braucht um das Objekt aus dem Datenstrom vollstndig wiederherzustellen. Dazu gehrt der Objekttyp ebenso wie die Inhalte aller Objektvariablen. Stark vereinfacht gesehen, knnte man toString zur Serialisierung verwenden. Diese Methode wandelt ein Objekt in eine Zeichenfolge um. Enthlt diese Zeichenfolge die gesamte Information ber das Objekt, kann daraus im Prinzip eine Kopie des Objekts erzeugt werden. In der Regel wird man eher eine eigene Methode denieren, die das Objekt beispielsweise in ein Array eines primitiven Typs (hauptschlich byte[] oder char[]) abbildet. Als Gegenstck wird man eine Methode denieren, die aus einem solchen Array wieder ein Objekt erzeugt. Zu Beginn der durch toString erzeugten Zeichenkette oder des durch andere Techniken erzeugten Arrays steht ein Identikator der Klasse des Objekts etwas, das die Klasse eindeutig beschreibt. Danach kommen der Reihe nach die Werte in den Objektvariablen. Da Referenzvariablen weitere Objekte referenzieren, fhrt die Serialisierung eines Objekts zur Serialisierung aller enthaltenen Objekte, wobei jedes dieser Objekte wieder durch einen Identikator der Klasse und die Werte der Objektvariablen beschrieben ist. Schwierig wird es, wenn zwischen den Objekten zyklische Abhngigkeiten bestehen: Wenn man nicht darauf aufpasst, dass jedes Objekt hchstens einmal codiert wird, ergibt sich eine Endlosschleife. Um mehrfache Codierungen zu vermeiden braucht man eine Mglichkeit, andere, bereits codierte Objekte zu referenzieren. Bei Bercksichtigung aller Sonderflle wird die Serialisierung recht aufwendig. Um aus den serialisierten Daten wieder entsprechende Objekte zu erzeugen sind die enthaltenen Identikatoren hilfreich. Jeder Identikator beschreibt, wie die folgenden Daten zu interpretieren sind. Pro Identikator wird eine Methode ausgefhrt, die das entsprechende Objekt aufbaut.
483
Diese Methode muss alle Details der externen Darstellung des Objekts genau kennen. Auch beim Aufbau mssen zyklische Abhngigkeiten bercksichtigt werden. Manchmal gibt es interne Objektdaten, die fr die externe Darstellung irrelevant sind. Man denke beispielsweise an eine Klasse, deren Objekte Zustnde eines Spiels darstellen, daneben aber auch Hilfsvariablen zur Vereinfachung von Berechnungen enthalten. Spielzustnde sollen in externen Darstellungen enthalten sein, Hilfsvariablen aber nicht, da ihre Inhalte aus den Spielzustnden berechnet werden knnen. Daher gibt es bei jeder Form der Serialisierung (Verwendung eigener Methoden oder vorgefertigter Klassen) die Mglichkeit, bestimmte Variablen auszuschlieen. In Java gibt es mehrere fertige Klassen und Klassenbibliotheken zur Serialisierung von Objekten. Eine vergleichsweise einfache Mglichkeit bietet die Klasse ObjectOutputStream mit der Methode writeObject: Ein Aufruf [Link](x) serialisiert das Objekt in x automatisch und sendet es zur Speicherung an den Stream o. Alles funktioniert so wie bei einer normalen Ausgabe. ber die Methode readObject der Klasse ObjectInputStream lassen sich die Objekte in derselben Reihenfolge, in der sie Ausgegeben wurden, wieder einlesen. Allerdings hat readObject den Ergebnistyp Object, sodass wir die eingelesenen Objekte durch einen Cast in den gewnschten Typ umwandeln mssen. Daher mssen wir die Typen eingelesener Objekte kennen. Objekte, die auf diese Weise serialisiert werden sollen, mssen das Interface Serializable implementieren. Dieses Interface speziziert keine Methoden, sondern markiert nur serialisierbare Objekte. Alle Referenzvariablen innerhalb des Objekts mssen ebenfalls serialisierbar sein, sonst gibt es eine Fehlermeldung. Um bestimmte Objektvariablen von der Serialisierung auszuschlieen, gibt es in Java den Modier transient, der vor die Variablendeklaration gestellt wird. So einfach diese Beschreibung klingt, so problematisch kann sich die Anwendung in der Praxis gestalten. Denn neben diesen Basismechanismen hat man es hug mit Sonderfllen zu tun, etwa mit Zyklen. Diese sind von Fall zu Fall verschieden zu behandeln. Auerdem muss man dafr sorgen, dass Objektvariablen, deren Werte nicht abgespeichert werden, bei der Wiederherstellung sinnvoll initialisert werden. Durch readObject wird auch nicht das ursprngliche Objekt wiederhergestellt, sondern eine Kopie davon, was interessante Wechselwirkungen mit der Methode clone ergeben kann. Serialisierung ist daher ein recht fortgeschrittenes Programmierkonzept, auf das wir hier nicht im Detail eingehen knnen.
484
7.6.2 Lesbare externe Darstellung Eine Form der externen Darstellung ist eine lesbare Zeichenfolge, wie sie von der Methode toString (siehe Abschnitt 3.4.3) geliefert wird. Diese Zeichenfolge kann auch persistent in einer Datei gespeichert werden. Die Methode toString steht fr jedes Objekt zur Verfgung, da sie in der Wurzelklasse Object deniert wird. Die Standardimplementierung in Object (siehe Listing 7.2) gibt den Klassennamen gefolgt vom hexadezimal dargestellten Hash-Wert als Zeichenkette zurck.
Listing 7.2: Die Standardimplementierung der Methode toString in Object public String toString() { return getClass().getName() + "@" + [Link](hashCode()); }
Wurde die Methode hashCode nicht berschrieben, entspricht der HashWert der Objektreferenz (oder einem Ausschnitt daraus). Der Hash-Wert hat allgemein nur wenig Aussagekraft ber den Objektzustand und ist daher fr die externe Darstellung nur in Ausnahmefllen geeignet. Es ist sinnvoll, in jeder Klasse toString so zu berschreiben, dass die Darstellung alle zur korrekten Verwendung von auen ntigen Daten des Objekts umfasst. Dabei sollte auch die Interpretation der Daten klar sein, beispielsweise indem die Daten benannt werden (z.B. Punkt2D(2,1)). Man hat, was die Form der Darstellung betrit, grundstzlich alle Freiheiten, da lediglich speziziert ist, dass eine lesbare Reprsentation des Objekts geliefert werden soll. Die Rckgabe von toString kann daher, so wie die von hashCode, nicht als isomorphe Abbildung des Objektzustandes gesehen werden. Das Objekt braucht aus dem String nicht wiederherstellbar zu sein. Aus diesem Grund ist toString in der Regel weder fr Objektvergleiche noch zur Serialisierung geeignet. Wenn die von toString zurckgegebene Zeichenkette nicht nur von auen verwendbare, sondern auch alle internen Daten enthalten wrde, knnte man toString immer fr die Serialisierung und auch zur Prfung der Gleichheit zweier Objekte verwenden. Davon wird aber dringend abgeraten: Interne Daten haben von auen betrachtet keinerlei Bedeutung und knnen daher nicht richtig interpretiert werden. Wenn man diese Daten sichtbar machen wrde, htte man die Trennung zwischen der Innenund Auenansicht des Objekts aufgehoben. Das wrde eine ganze Reihe schwerwiegender Probleme mit sich bringen, da die Innenansicht einen
485
ganz anderen Informationsgehalt hat als die Auenansicht. Unterschiedliche Personen wrden die Information, die im Objekt steckt, unterschiedlich interpretieren. Dadurch wrden auch Untertypbeziehungen verletzt. Ohne klare Trennung der Innen- von der Auenansicht wrde die objektorientierte Programmierung kaum funktionieren. Daher zahlt es sich sicher nicht aus, die Trennung aufzugeben, nur damit man eine einzige Methode auf nicht vorgesehene Weise fr andere Zwecke verwenden kann. Weder durch toString noch durch Serialisierung erzeugte externe Darstellungen von Objekten mssen alle Daten des Objekts enthalten. Jedoch sind die Grnde fr das Weglassen ganz verschieden: Lesbare externe Darstellungen sollen keine Daten enthalten, die von auen nicht einheitlich interpretierbar sind. Durch Serialisierung erzeugte externe Darstellungen mssen dagegen auch interne Daten enthalten, knnen aber Daten weglassen, die beim Wiederherstellen des Objekts berechenbar sind. Wegen dieses Unterschieds sind durch Serialisierung erzeugte externe Darstellungen meist nicht lesbar. Nur spezielle Methoden haben die ntigen Informationen um die externe Darstellung richtig zu interpretieren. Trotzdem versucht man immer wieder, z.B. ber XML lesbare externe Darstellungsformen zum Zwecke der Serialisierung zu nden. Auer in ganz einfachen Fllen wird man kaum gute Lesbarkeit erzielen: Information ber die richtige Interpretation der Daten fehlt auch in diesen Anstzen. Eine Beispiel dazu liefern Java-Beans: Darunter versteht man im Wesentlichen einen ber Konventionen bestimmten Programmierstil in Java, bei dem die Unterscheidung zwischen der Innen- und Auenansicht von Objekten weitgehend aufgehoben wird. ber Java-Beans lassen sich grasche Benutzeroberchen ganz einfach realisieren. Java-Beans (also Objekte, die den Konventionen entsprechen) knnen ber die Klasse XMLEncoder ohne groen Aufwand in eine lesbare externe Darstellung und ber XMLDecoder wieder in Objekte umgewandelt werden. Das funktioniert nur deswegen so einfach, weil Java-Beans durch diverse Einschrnkungen auf viele positive Eigenschaften blicher Objekte verzichten.
486
Wie man am Namen unschwer erkennen kann, hat Informatik viel mit dem Umgang mit Informationen zu tun. Hinter fast allem, was man in der Informatik macht, steckt die Grundidee, Informationen mglichst eektiv zu verwenden. Kaum ein Bereich bleibt davon unberhrt. Entsprechend vielschichtig und schwer fassbar ist der Informationsbegri. 7.7.1 Kontrollfragen Was versteht man in der Informationstheorie nach Shannon unter einer Nachricht und dessen Informationsgehalt? Wie wird der Informationsgehalt berechnet? Was ist Entropie im Kontext der Informationstheorie nach Shannon? Was versteht man unter einer Codierung? Was ist der Unterschied zwischen syntaktischer und semantischer Information? Wie ist Informationsgehalt auf syntaktischer Ebene quantizierbar, wie auf semantischer? Wodurch wird die Entropie bestimmt? Wie hngt die Entropie mit der Codelnge zusammen? Unter welchen Annahmen gilt dieser Zusammenhang? Wie ist Redundanz deniert? Was ist die Prx-Bedingung? Was versteht man unter Datenkompression? Welche Arten werden unterschieden? Wieso knnen komprimierte Daten krzer sein als deren durch die Entropie vorgegebene Minimallngen? Was versteht man unter Verschlsselung? Wie hngt Wissen mit Information zusammen? Was ist das Entziern von Daten und wie kann dies gelingen? Wie und wo kann man Information verstecken?
487
Wie kann man als Mensch mit der hohen Informationsdichte in Programmen zurechtkommen? Wieso kann das Verstecken von Code in Programmen ein Problem sein? Wie ist eine Fliekommazahl aufgebaut? Was ist Plain-Text ? Was ist Unicode ? Was bedeutet UTF-8, UTF-16 bzw. UTF-32? In welcher Weise knnen relative Hugkeiten von Zeichen in einem Text bei der Codierung bercksichtigt werden? Was unterscheidet strukturelle von nominalen Typen, und wie hngen diese Begrie mit Information zusammen? Welche Daten liegen am Heap, welche am Stack ? Was ist die Method-Area ? Zu welchem Zeitpunkt (Laufzeit oder Compilezeit) werden lokale Variablen aufgelst? Wie funktioniert das dynamische Binden mittels VFT? Was ist der Konstantenpool? Warum haben Untertypen einen hheren Informationsgehalt als deren Obertypen? Welchen Mglichkeiten zur Serialisierung von Objekten gibt es, und welche Schwierigkeiten sind dabei zu bercksichtigen? Warum eignet sich toString kaum zur Serialisierung?
488
Rekursive Methoden sind oft kürzer und konzeptuell einfacher zu verstehen, da sie in natürlicher Weise die rekursive Form der Datenstruktur widerspiegeln. Iterative Methoden hingegen ersetzen Rekursion durch Schleifen, was manchmal komplizierter ist, aber die gleichen Algorithmen ohne die Gefahr von Stackoverflow-Fehlern umsetzen kann. Obwohl die rekursive Implementierung oft als eleganter angesehen wird, empfinden unerfahrene Programmierer sie als schwieriger .
Datenabstraktion durch Sichtbarkeit ermöglicht es, die internen Details eines Objekts zu verbergen und nur die notwendigen Informationen offen zu legen. In Java wird dies durch die Verwendung von Modifikatoren wie 'private' erreicht, die verhindern, dass auf Variablen von außerhalb der Klasse zugegriffen wird. Dies fördert die Kapselung, indem es direkte Zugriffe auf die Objektvariablen 'x' und 'y' verhindert, obwohl sie innerhalb jeder Instanz existieren .
In Java erstreckt sich der Gültigkeitsbereich von formalen Parametern über den gesamten Methodenrumpf. Sie funktionieren ähnlich wie lokale Variablen, allerdings nehmen sie beim Aufruf der Methode Werte entgegen, die der Aufrufer zur Verfügung stellen muss. Diese Werte werden dann während der Methodenausführung verwendet .
Klassen in Java definieren die Struktur und Implementierung von Objekten und können konkrete Methoden und Variablen enthalten. Interfaces hingegen definieren nur Methodensignaturen und möglicherweise Konstanten, ohne Implementierung. Klassen können mehrere Interfaces implementieren, was Flexibilität in der Strukturierung von Code bietet. Interfaces erlauben es, den Implementierungsdetails zu abstrahieren und mehrere Verwendungen desselben Objekttypus zu definieren .
JPF ist ein Tool zur Qualitätssicherung von Java-Programmen, das verwendet wird, um die Einhaltung von Zusicherungen in Form von Vorbedingungen, Nachbedingungen und Invarianten zu überprüfen. Dies geschieht vor der Programmausführung und kann die Zuverlässigkeit des Programms erheblich verbessern, da potenzielle Fehler erkannt und behoben werden können, bevor sie während der Laufzeit auftreten .
Rekursive Methoden sind oft kürzer und simpler auszudrücken, da sie die natürliche rekursive Struktur der zugrundeliegenden Datenstrukturen nutzen. Sie können eine klarere und direktere Wendung zum Problem darstellen als iterative Methoden, die tendenziell komplizierter sind, da Schleifen die Logik expliziter umsetzen müssen .
Die Sichtbarkeit von Klassen in Java kann mit Modifikatoren wie 'public' und 'private' gesteuert werden. 'Public'-Klassen sind überall im Programm sichtbar, während 'private'-Klassen nur innerhalb der sie enthaltenden Datei sichtbar sind. Ein Sonderfall sind 'public'-Klassen, die global als Klassentypen nutzbar sind, sogar wenn sie allein in einer Datei stehen .
Eine Herausforderung der Programmiersprachenentwicklung im Vergleich zu Java besteht darin, sich an neue Gegebenheiten und Anforderungen schnell anzupassen, während Java aufgrund seiner weit verbreiteten Nutzung und großen Entwicklercommunity oft langsamer Anpassungen vornimmt. Dies führt dazu, dass neuere, weniger etablierte Sprachen dynamischer sind, um an Bedeutung zu gewinnen .
Testfälle dienen der Prüfung des gewünschten Verhaltens von Programmen durch formelle Definition von Eingabe-Ausgabe-Beziehungen. Sie helfen, spezielle Fälle und Grenzbedingungen zu überprüfen. Im Gegensatz zu statischen Analysen, die sich auf das Verstehen des Codes konzentrieren, dienen Testfälle eher der dynamischen Verifikation, decken aber nicht alle möglichen Fälle ab. Statische Analysen bieten tiefere Einblicke und helfen besonders dabei, Fehler frühzeitig zu erkennen, indem sie das Programm umfassend verstehen lassen .
In Java bestimmt die Methodensignatur, bestehend aus dem Rückgabetyp, dem Methodennamen und der Liste der Typen der formalen Parameter, welche Methode bei einem Methodenaufruf gewählt wird. Die Auswahl einer Methode bei überladenen Methoden hängt von der Übereinstimmung der Argumentliste mit der formalen Parameterliste ab, nicht jedoch von den Namen der Parameter .