ProgrammierenIWS 03/04Sven Eric PanitzTFH BerlinDieses Skript entstand begleitend zur Vorlesung des
WS 02/03 vollkommen neu und stellte somit eine Mitschrift der Vorlesung dar.
Naturgemäß ist es in Aufbau und Qualität nicht mit einem Buch
vergleichbar. Flüchtigkeits- und Tippfehler ließen sich im Eifer
des Gefechtes nicht vermeiden und traten in nicht
geringem Maße auf. Ich bin natürlich stets dankbar, wenn
ich auf solche aufmerksam gemacht werde und diese korrigieren kann.
Diese Version des Skriptes ist eine Neuauflage für das WS03/04, in der einige
Umstrukturierungen und Korrekturen vorgenommen wurden.
Besonderen Dank gebührt Christian Achter, der gewissenhaft dieses Skript auf
Flüchtigkeits und Rechtschreibfehler durchgelesen hat.
Der Quelltext dieses Skripts ist eine XML-Datei, die durch eine XQuery
in eine -Datei transformiert und für die schließlich eine
pdf-Datei und eine postscript-Datei
erzeugt werden. Der XML-Quelltext verweist direkt auf ein XSLT-Skript,
das eine HTML-Darstellung erzeugt, so daß ein entsprechender Browser
mit XSLT-Prozessor die XML-Datei direkt als HTML-Seite darstellen kann.
Diese Vorlesung setzt sich zum Ziel, die Grundlagen der Programmierung zu
vermitteln. Hierzu gehören insbesondere gängige Algorithmen und
Programmiermuster sowie die gebräuchlichsten Datentypen. Die verwendete
Programmiersprache ist aus pragmatischen Gründen Java. Die
Vorlesung will aber kein reiner Javakurs sein, sondern ermöglichen, die
gelernten Konzepte schnell auch in anderen Programmiersprachen umzusetzen.
Programmierung wird nicht allein als die eigentliche Codierung des
Programms verstanden, sondern Tätigkeiten wie Spezifikation, Modellierung,
Testen etc.werden als Teildisziplinen der Programmierung verstanden.
Desweiteren soll die Vorlesung mit der allgemeinen Terminologie der Informatik
vertraut machen.
Mit dem Begriff Programmierung wird zunächst
die eigentliche Codierung eines Programms assoziiert.
Eine genauerer Blick offenbart jedoch, daß dieses nur ein kleiner
Teil von vielen recht unterschiedlichen Schritten ist, die zur
Erstellung von Software notwendig sind:
Spezifikation: Bevor eine Programmieraufgabe
bewerkstelligt werden kann, muß das zu lösende
Problem spezifiziert werden. Dieses kann informell durch eine
natürlichsprachliche Beschreibung bis hin zu mathematisch
beschriebenen Funktionen geschehen. Gegen die Spezifikation
wird programmiert. Sie beschreibt das gewünschte Verhalten
des zu erstellenden Programms. Modellieren: Bevor es an die eigentliche Codierung
geht, wird in der Regel die Struktur des Programms modelliert.
Auch dieses kann in unterschiedlichen Detaillierungsgraden geschehen.
Manchmal reichen Karteikarten als hilfreiches Mittel aus,
andernfalls empfiehlt sich eine umfangreiche Modellierung
mit Hilfe rechnergestützter Werkzeuge. Für die objektorientierte
Programmierung hat sich UML als eine geeignete Modellierungssprache
durchgesetzt. In ihr lassen sich Klassendiagramme, Klassenhierarchien
und Abhängigkeiten graphisch darstellen. Mit bestimmten Werkzeugen
wie Together oder Rational Rose läßt sich direkt
für eine UML-Modellierung Programmtext generieren.Codieren: Die eigentliche Codierung ist in der
Regel der einzige Schritt, der direkt Code in der gewünschten
Programmiersprache von Hand erzeugt. Alle anderen Schritte
der Programmierung
sind mehr oder weniger unabhängig von der zugrundeliegenden
Programmiersprache.
Für die Codierung empfiehlt es sich, Konventionen zu verabreden, wie
der Code geschrieben wird, was für Bezeichner benutzt werden, in
welcher Weise der Programmtext eingerückt wird. Entwicklungsabteilungen
haben zumeist schriftlich verbindlich festgeschriebene Richtlinien
für den Programmierstil. Dieses erleichtert, den Code der Kollegen im
Projekt schnell zu verstehen.
Testen: Beim Testen sind generell zu unterscheiden:
Entwicklertests: Diese werden von den Entwicklern
während der Programmierung selbst geschrieben, um einzelne Programmteile
(Methoden,
Funktionen) separat zu testen. Es gibt eine Schule, die
propagiert, Entwicklertests vor dem Code zu
schreiben (test first). Die Tests dienen in diesem Fall als
kleine Spezifikationen.Qualitätssicherung: In der Qualitätssicherung werden
die fertigen Programme gegen ihre Spezifikation getestet (black
box tests). Hierzu
werden in der Regel automatisierte Testläufe geschrieben.
Die Qualitätssicherung ist personell von der Entwicklung
getrennt. Es kann
in der Praxis durchaus vorkommen, daß die Qualitätsabteilung
mehr Mitarbeiter hat als die Entwicklungsabteilung.Optimieren: Sollten sich bei Tests oder in der
Praxis Performanzprobleme zeigen, sei es durch zu hohen
Speicherverbrauch als auch durch zu lange Ausführungszeiten, so wird
versucht, ein Programm zu optimieren. Hierzu bedient man sich
spezieller Werkzeuge (profiler), die für einen Programmdurchlauf
ein Raum- und Zeitprofil erstellen. In diesem Profil können
Programmteile, die besonders häufig durchlaufen werden, oder Objekte,
die im großen Maße Speicher belegen, identifiziert werden. Mit diesen
Informationen lassen sich gezielt inperformante Programmteile
optimieren.Verifizieren: Eine formale Verifikation eines Programms
ist ein mathematischer Beweis der Korrektheit bezüglich der
Spezifikation. Das setzt natürlich voraus, daß die Spezifikation
auch formal vorliegt. Man unterscheidet:
partielle Korrektheit: wenn das Programm für
eine bestimte Eingabe ein Ergebnis liefert, dann ist dieses bezüglich
der Spezifikation korrekt.totale Korrektheit: Das Programm ist partiell korrekt
und terminiert für jede Eingabe, d.h.liefert immer nach endlich
langer Zeit ein Ergebnis.
Eine formale Verifikation ist notorisch schwierig und allgemein nicht
automatisch durchführbar. In der Praxis werden nur in ganz speziellen
kritischen Anwendungen formale Verifikationen durchgeführt, z.B.bei
Steu\-er\-un\-gen gefahrenträchtiger Maschinen, so daß Menschenleben von
der Korrektheit eines Programms abhängen können.
Wartung/Pflege: den größten Teil seiner Zeit verbringt
ein Programmierer nicht mit der Entwicklung neuer
Software, sondern mit der Wartung bestehender Software. Hierzu gehören
die Anpassung des Programms an neue Versionen benutzter Bibliotheken
oder des Betriebssystems, auf dem das Programm läuft,
sowie die Korrektur von Fehlern.Debuggen: Bei einem Programm ist immer damit
zu rechnen, daß es Fehler enthält. Diese Fehler werden im besten
Fall von der Qualitätssicherung entdeckt, im schlechteren Fall treten
sie beim Kunden auf. Um Fehler im Programmtext zu finden,
gibt es Werkzeuge, die ein schrittweises Ausführen des Programms
ermöglichen (debugger). Dabei lassen sich die Werte, die in
bestimmten Speicherzellen stehen, auslesen und auf diese Weise der Fehler
finden.
Internationalisieren (I18N)I18N ist eine Abkürzung
für das Wort internationalization, das mit einem i beginnt, mit
einem n endet und dazwischen 18 Buchstaben hat.: Softwarefirmen
wollen möglichst
viel Geld mit ihrer Software verdienen und streben deshalb an, ihre
Programme möglichst weltweit zu vertreiben. Hierzu muß gewährleistet
sein, daß das Programm auf weltweit allen Plattformen läuft und
mit verschiedenen Schriften und Textcodierungen umgehen kann. Das
Programm sollte ebenso wie mit lateinischer Schrift auch mit Dokumenten
in anderen Schriften umgehen können. Fremdländische Akzente und
deutsche Umlaute sollten bearbeitbar sein. Aber auch unterschiedliche
Tastaturbelegungen bis hin zu unterschiedlichen Schreibrichtungen sollten
unterstützt werden.
Die Internationalisierung ist ein weites Feld, und wenn nicht am Anfang
der Programmerstellung hierauf Rücksicht genommen wird, so ist es schwer,
nachträglich das Programm zu internationalisieren.
Lokalisieren (L12N): Ebenso wie die
Internationalisierung beschäftigt
sich die Lokalisierung damit, daß ein Programm in anderen Ländern
eingesetzt werden kann. Beschäftigt sich die Internationalisierung damit,
daß fremde Dokumente bearbeitet werden können, versucht die Lokalisierung,
das Programm komplett für die fremde Sprache zu übersetzen. Hierzu
gehören Menueinträge in der fremden Sprache, Beschriftungen der
Schaltflächen oder auch Fehlermeldungen in fremder Sprache und Schrift.
Insbesondere haben verschiedene Schriften unterschiedlichen
Platzbedarf; auch das ist beim Erstellen der Programmoberfläche
zu berücksichtigen.Portieren: Oft wird es nötig, ein Programm auf eine andere
Plattform zu portieren. Ein unter Windows erstelltes Programm soll
z.B.auch auf Unix-Systemen zur Verfügung stehen. Dokumentieren: Der Programmtext allein reicht in der Regel
nicht aus, damit das Programm von Fremden oder
dem Programmierer selbst nach
geraumer Zeit gut verstanden werden kann. Um ein Programm näher
zu erklären, wird im Programmtext Kommentar eingefügt. Kommentare
erklären die benutzten Algorithmen, die Bedeutung bestimmter Datenfelder
oder die Schnittstellen und Benutzung bestimmter Methoden.
Es ist zu empfehlen, sich anzugewöhnen, Quelltextdokumentation immer auf
Englisch zu schreiben. Es ist oft nicht abzusehen, wer einmal einen
Programmtext zu sehen bekommt. Vielleicht ein japanischer Kollege,
der das Programm für Japan lokalisiert, oder der irische Kollege, der,
nachdem die Firma mit einer anderen Firma fusionierte, das
Programm auf ein anderes Betriebssystem portiert, oder vielleicht
die englische Werksstudentin, die für ein Jahr in der Firma arbeitet.
Die Frage danach, was ein Programm eigentlich ist, läßt sich
aus verschiedenen Perspektiven recht unterschiedlich beantworten.
Eine Textdatei, die durch ein anderes Programm in einen
ausführbaren Maschinencode
übersetzt wird. Dieses andere Programm ist ein Übersetzer,
engl.compiler.
Eine Funktion, die deterministisch für Eingabewerte
einen Ausgabewert berechnet.
Ein Satz einer durch eine Grammatik beschriebenen Sprache mit einer
operationalen Semantik.
Eine Folge von durch den Computer ausführbaren Befehlen, die den Speicher des
Computers manipulieren.
Es gibt mittlerweile mehr Programmiersprachen als
natürliche Sprachen.Wer Interesse hat, kann
im Netz einmal suchen, ob er eine Liste
von Programmiersprachen findet. Die meisten Sprachen führen
entsprechend nur ein Schattendasein und die Mehrzahl der Programme
konzentriert sich auf einige wenige Sprachen. Programmiersprachen lassen
sich nach den unterschiedlichsten Kriterien klassifizieren.
Im folgenden eine hilfreiche Klassifizierung in fünf verschiedene
Hauptklassen.
imperativ (C, Pascal, Fortran, Cobol): das
Hauptkonstrukt dieser Sprachen sind Befehle, die den Speicher
manipulieren.
objektorientiert (Java, C++, C\#, Eiffel, Smalltalk):
Daten werden in Form von Objekten organisiert. Diese Objekte
bündeln mit den Daten auch die auf diesen Daten anwendbaren
Methoden.funktional (Lisp, ML, Haskell, Scheme, Erlang, Clean):
Programme werden als mathematische Funktionen verstanden und auch
Funktionen können Daten sein. Dieses Programmierparadigma versucht,
sich möglichst weit von der Architektur des Computers zu lösen.
Veränderbare Speicherzellen gibt es in rein funktionalen Sprachen
nicht und erst recht keine Zuweisungsbefehle.Skriptsprachen (Perl, AWK): solche Sprachen sind dazu
entworfen, einfache kleine Programme schnell zu erzeugen. Sie haben
meist kein Typsystem und nur eine begrenzte Zahl an
Strukturierungsmöglichkeiten, oft aber eine mächtige Bibliothek, um
Zeichenketten zu manipulieren.logisch (Prolog): aus der KI (künstlichen Intelligenz)
stammen logische Programmiersprachen. Hier wird ein Programm als
logische Formel, für die ein Beweis gesucht wird, verstanden.
Der Programmierer schreibt den lesbaren Quelltext seines Programmes.
Um ein Programm auf einem Computer laufen zu lassen, muß es erst
in einen Programmcode übersetzt werden, den der Computer versteht.
Für diesen Schritt gibt es auch unterschiedliche Modelle:
kompiliert (C, Cobol, Fortran): in einem Übersetzungsschritt
wird aus dem Quelltext direkt das ausführbare Programm erzeugt, das dann
unabhängig von irgendwelchen Hilfen der Programmiersprache ausgeführt werden
kann.
interpretiert (Lisp, Scheme): der Programmtext wird nicht
in eine ausführbare Datei übersetzt, sondern durch einen Interpreter
Stück für Stück anhand des Quelltextes ausgeführt. Hierzu muß stets der
Interpreter zur Verfügung stehen, um das Programmm auszuführen.
Interpretierte Programme sind langsamer in der Ausführung als
übersetzte Programme.abstrakte Maschine über byte code (Java, ML):
dieses ist quasi eine Mischform aus den obigen zwei Ausführungsmodellen.
Der Quelltext wird übersetzt in Befehle nicht für einen konkreten
Computer, sondern für eine abstrakte Maschine. Für diese abstrakte Maschine
steht dann ein Interpreter zur Verfügung. Der Vorteil ist, daß durch
die zusätzliche Abstraktionsebene der Übersetzer unabhängig von einer konkreten
Maschine Code erzeugen kann und das Programm auf auf allen Systemen laufen
kann, für die es einen Interpreter der abstrakten Maschine gibt.
Es gibt Programmiersprachen, für die sowohl Interpreter als auch Übersetzer
zur Verfügung stehen. In diesem Fall wird der Interpreter gerne zur
Programmentwicklung benutzt und der Übersetzer erst, wenn das Programm
fertig entwickelt ist.
Da Java sich einer abstrakten Maschine bedient, sind, um zur Ausführung
zu gelangen, sowohl
ein Übersetzer als auch ein Interpreter notwendig.
Der zu übersetzende Quelltext steht in Dateien mit der Endung .java,
der erzeugte byte code in Dateien mit der Endung .class
Der Javaübersetzer kann von der Kommandozeile mit dem Befehl javac
aufgerufen werden. Um eine Programmdatei Test.java zu übersetzen,
kann folgendes Kommando eingegeben werden:
javac Test.java
Im Falle einer fehlerfreien Übersetzung wird eine Datei Test.class
im Dateisystem erzeugt. Dieses erzeugte Programm wird allgemein
durch folgendes Kommando im Javainterpreter ausgeführt:
java Test
Um ein lauffähiges Javaprogramm zu schreiben, ist es notwendig,
eine ganze Reihe von Konzepten Javas zu kennen, die nicht eigentliche
Kernkonzepte der objektorientierten Programmierung sind.
Daher geben wir hier ein minimales
Programm an, daß eine Ausgabe auf den Bildschirm macht:
class FirstProgram{
public static void main(String [] args){
System.out.println("hello world");
}
}
In den kommenden Wochen werden wir nach und nach die einzelne Bestandteile
dieses Minimalprogrammes zu verstehen lernen.
Schreiben Sie das obige Programm mit einen Texteditor ihrer
Wahl. Speichern Sie es als FirstProgram.java ab.
Übersetzen Sie es mit dem Java-Übersetzer javac.
Es entsteht eine Datei FirstProgram.class. Führen
Sie das Programm mit
dem Javainterpreter java aus. Führen Sie dieses sowohl einmal auf
Linux als auch einmal unter Windows durch.
Wir können in der Folge diesen Programmrumpf benutzen, um beliebige Objekte
auf den Bildschirm auszugeben. Hierzu werden wir das "hello world"
durch andere Ausdrücke ersetzen.
Wollen Sie z.B. eine Zahl auf dem Bildschirm ausgeben, so ersetzen Sie
den in Anführungszeichen eingeschlossenen Ausdruck durch diese Zahl:
class Answer {
public static void main(String [] args){
System.out.println(42);
}
}
Bevor im nächsten Kapitel mit der eigentlichen objektorientierten
Programmierung begonnen wird, wollen wir
für den weiteren Verlauf der Vorlesung eine Arbeitshypothese
aufstelllen:
Beim Programmieren versuchen wir, zwischen Daten und Programmen
zu unterscheiden. Programme manipulieren Daten, indem sie sie
löschen, anlegen oder überschreiben.
Daten können dabei einfache Datentypen sein, die Zahlen, Buchstaben oder
Buchstabenketten (Strings) repräsentieren, oder aber beliebig strukturierte
Sammlungen von Daten wie z.B.Listen, Tabellen, Baum- oder Graphstrukturen.
Wir werden im Laufe der Vorlesung immer wieder zu prüfen haben, ob diese
starke Trennung zwischen Daten und Programmen gerechtfertigt ist.
Die Grundidee der objektorientierten Programmierung ist, Daten,
die zusammen ein größeres zusammenhängendes Objekt beschreiben,
zusammenzufassen. Zusätzlich fassen wir mit diesen Daten
noch die
Programmteile zusammen, die diese Daten manipulieren. Ein Objekt enthält
also nicht nur die reinen Daten,
die es repräsentiert, sondern auch Programmteile, die Operationen auf diesen
Daten durchführen. Insofern wäre vielleicht subjektorientierte
Programmierung
ein passenderer Ausdruck, denn die Objekte sind nicht passive Daten, die von
außen manipuliert werden, sondern enthalten selbst als integralen Bestandteil
Methoden, die ihre Daten manipulieren können.
Bevor wir etwas in Code gießen, wollen wir ersteinmal eine informelle
Modellierung der Welt, für die ein Programm geschrieben werden soll,
vornehmen. Hierzu empfiehlt es sich durchaus, in einem Team zusammenzusitzen
und auf Karteikarten aufzuschreiben, was es denn für Objekte in der Welt gibt,
die wir modellieren wollen.
Stellen wir uns hierzu einmal vor, wir sollen ein Programm zur
Bibliotheksverwaltung schreiben. Jetzt überlegen wir einmal, was gibt es denn
für Objektarten, die alle zu den Vorgängen in einer Bibliothek gehören. Hierzu
fällt uns vielleicht folgende Liste ein:
Personen, die Bücher ausleihen wollen.Bücher, die ausgeliehen werden können.Tatsächliche Ausleihvorgänge, die ausdrücken, daß ein Buch bis zu einem
bestimmten Zeitraum von jemanden ausgeliehen wurde.Termine, also Objekte, die ein bestimmtes Datum kennzeichnen.
Nachdem wir uns auf diese vier für unsere Anwendung wichtigen Objektarten
geinigt haben, nehmen wir vier Karteikarten und schreiben jeweils eine der
Objektarten als Überschrift auf diese Karteikarten.
Jetzt haben wir also Objektarten identifiziert. Im nächsten Schritt ist zu
überlegen, was für Eigenschaften diese Objekte haben. Beginnen wir für die
Karteikarte, auf der wir als Überschrift Person geschrieben haben.
Was interessiert uns an Eigenschaften einer Person? Wahrscheinlich ihr Name
mit Vornamen, Straße und Ort sowie Postleitzahl. Das sollten die Eigenschaften
einer Person sein, die für ein Bibliotheksprogramm notwendig sind. Andere
mögliche Eigenschaften wie Geschlecht, Alter, Beruf oder ähnliches
interessieren uns in diesem Kontext nicht. Jetzt schreiben wir die
Eigenschaften, die uns von einer Person interessieren, auf die Karteikarte mit
der Überschrift Person.
Schließlich müssen wir uns Gedanken darüber machen, was diese Eigenschaften
eigentlich für Daten sind. Name, Vorname, Straße und Wohnort sind sicherlich
als Texte abzuspeichern oder, wie der Informatiker gerne sagt, als
Zeichenketten. Die Postleitzahl ist hingegen als eine Zahl
abzuspeichern. Diese Art, von der die einzelnen Eigenschaften sind, nennen wir
ihren Typ. Wir schreiben auf die Karteikarte für die
Objektart Person vor jede der Eigenschaften noch den Typ, den diese
Eigenschaft hat.Es mag vielleicht verwundern, warum wir den Typ vor
die Eigenschaft und nicht etwa hinter sie schreiben. Dieses ist eine sehr alte
Tradition in der Informatik. Damit erhalten wir für die
Objektart Person die in Abbildung gezeigte Karteikarte.
Gleiches können wir für die Objektart Buch und für die
Objektart Datum machen. Wir erhalten dann eventuell die Karteikarten
aus Abbildung und .
Wir müssen uns schließlich nur noch um die Objektart einer Buchausleihe
kümmern. Hier sind drei Eigenschaften interessant: wer hat das Buch geliehen,
welches Buch wurde verliehen und wann muß es zurückgegeben werden. Wir können
also drei Eigenschaften auf die Karteikarte schreiben. Was sind die Typen
dieser drei Eigenschaften? Diesmal sind es keine Zahlen oder Zeichenketten,
sondern Objekte der anderen drei bereits modellierten Objektarten. Wenn wir
nämlich eine Karteikarte schreiben, dann erfinden wir gerade einen neuen Typ,
den wir für die Eigenschaften anderer Karteikarten benutzen können.
Somit erstellen wir eine Karteikarte für den Objekttyp Ausleihe, wie
sie in Abbildung zu sehen ist.
Wir haben in einem Modellierungsschritt im letzten Abschnitt verschiedene
Objektarten identifiziert und ihre Eigenschaften spezifiziert. Dazu haben
wir vier Karteikarten geschrieben. Jetzt können wir versuchen, diese Modellierung
in Java umzusetzen.
In Java beschreibt eine Klasse eine Menge von Objekten gleicher Art.
Damit entspricht eine Klasse einer der Karteikarten in unsrer Modellierung.
Die
Klassendefinition ist eine Beschreibung der möglichen Objekte. In ihr
ist definiert, was für Daten zu den Objekten gehören. Zusätzlich können
wir in einer Klasse noch schreiben, welche Operationen
auf diesen Daten angewendet werden können. Klassendefinitionen sind die
eigentlichen Programmtexte, die der Programmierer schreibt.
In Java steht
genau eine KlassendefinitionAuch hier werden wir Ausnahmen
kennenlernen. in genau einer Datei.
Die Datei hat dabei den Namen der Klasse mit der Endung .java.
In Java wird eine Klasse durch das Schlüsselwort class, gefolgt
von dem Namen, den man für die Klasse gewählt hat, deklariert.
Anschließend folgt in
geschweiften Klammern der Inhalt der Klasse bestehend aus Felddefinitionen
und Methodendefinitionen.
Die einfachste Klasse, die in Java denkbar ist, ist eine Klasse ohne
Felder oder Methoden:
class Minimal {
}
Beachten Sie, daß Groß- und Kleinschreibung in Java relevant ist.
Alle Schlüsselwörter wie class werden stets klein geschrieben.
Klassennamen starten per Konvention immer mit einem Großbuchstaben.
Java kommt bereits mit einer großen Anzahl zur Verfügung stehender
Standardklassen. Es müssen also nicht alle Klassen neu vom Programmierer
definiert werden. Eine sehr häufig benutzte Klasse ist die
Klasse String. Sie repräsentiert Objekte, die eine Zeichenkette
darstellen, also einen Text, wie wir ihn in unserer ersten Modellierung
bereits vorausgesetzt haben..
Eine Klasse deklariert, wie die Objekte,
die die Klasse beschreibt, aussehen
können. Um konkrete Objekte für eine Klasse zu bekommen, müssen diese
irgendwann im Programm einmal erzeugt werden. Dieses wird in Java
syntaktisch gemacht, indem einem Schlüsselwort new der Klassenname
mit einer in runden Klammern eingeschlossenen Argumentliste folgt.
Ein Objekt der obigen minimalen Javaklasse läßt sich entsprechend
durch folgenden Ausdruck erzeugen:
new Minimal();Schreiben sie ein Programm, das ein Objekt der Klasse
Minimal erzeugt und auf dem Bildschirm ausgibt. Hierzu ersetzen sie
einfach im Programm Answer die 42 durch den
Ausdruck new Minimal()
Wenn ein Objekt eine Instanz einer bestimmten Klasse ist, spricht man auch
davon, daß das Objekt den Typ dieser Klasse hat.
Für die Klasse String gibt es eine besondere Art, Objekte zu
erzeugen. Ein in Anführungsstrichen eingeschlossener Text erzeugt ein
Objekt der Klasse String.
Aus zwei Objekten der Stringklasse
läßt sich ein neues Objekt erzeugen, indem diese beiden Objekte mit
einem Pluszeichen verbunden werden:
"hallo "+"welt"
Hier werden die zwei Stringobjekte "hallo " und "welt"
zum neuen Objekt "hallo welt" verknüpft.
Im obigen Abschnitt haben wir gesehen, wie eine Klasse definiert wird und
Objekte einer Klasse erzeugt werden können. Allerdings war unsere erste
Klasse noch vollkommen ohne Inhalt: es gab weder die Möglichkeit,
Daten zu speichern, noch wurden Programmteile definiert, die hätten
ausgeführt werden können.
Hierzu können Felder und Methoden deklariert
werden. Die Gesamtheit der
Felder und Methoden nennt man mitunter
auch Features oder, um einen griffigen deutschen Ausdruck zu
verwenden, Eigenschaften.
Zum Speichern von Daten können Felder für eine Klasse definiert werden.
In einem Feld können Objekte für eine bestimmte Klasse gespeichert werden.
Bei der Felddeklaration wird angegeben, welche Art von Objekten in einem
Feld abgespeichert werden sollen. Die Felder entsprechen dabei genau den
Eigenschaften, die wir auf unsere Karteikarten geschrieben haben.
Syntaktisch wird in Java der Klassenname des Typs, von dem Objekte
gespeichert werden sollen, den frei zu wählenden Feldnamen vorangestellt.
Eine Felddeklaration endet mit einem Semikolon.
Im Folgenden schreiben wir eine Klasse mit zwei Feldern:
class ErsteFelder {
Minimal meinFeld;
String meinTextFeld;
}
Das erste
Feld soll dabei einmal ein Objekt unserer minimalen Klasse sein und
das andere Mal eine Zeichenkette.
Feldnamen werden per Konvention immer klein geschrieben.
Schreiben Sie für die vier Karteikarten in der Modellierung eines
Bibliotheksystems entsprechende Klassen mit den entsprechenden Feldern.
In Feldern können Objekte gespeichert werden. Hierzu muß dem Feld ein
Objekt zugewiesen werden. Syntaktisch geschieht dieses durch ein
Gleichheitszeichen, auf dessen linker Seite das Feld steht und auf dessen
rechter Seite das Objekt, das in diesem Feld gespeichert werden soll.
Auch Zuweisungen enden mit einem Semikolon.
In der folgenden Klasse definieren wir nicht nur, daß die Objekte der Klasse
zwei Felder eines bestimmten Typs haben, sondern weisen auch gleich schon
Werte, d.h. konkrete Daten diesen Feldern zu.
class ErsteFelder2 {
Minimal meinFeld = new Minimal();
String meinTextFeld = "hallo";
}
Nach einer Zuweisung repräsentiert das Feld das ihm zugewiesene Objekt.
MethodenDer
Ausdruck für Methoden kommt speziell aus der
objektorientierten Prorammierung. In der imperativen Programmierung
spricht man von Prozeduren, die funktionale Programmierung
von Funktionen. Weitere Begriffe, die Ähnliches beschreiben,
sind Unterprogramme und Subroutinen.
sind die Programmteile, die in einer Klasse definiert sind und für
jedes Objekt dieser Klasse zur Verfügung stehen. Die Ausführung einer
Methode liefert meist ein Ergebnisobjekt. Methoden haben eine Liste von
Eingabeparametern. Ein Eingabeparameter ist durch den gewünschten
Klassennamen und einen frei wählbaren Parameternamen spezifiziert.
In Java wird eine Methode deklariert durch:
den Rückgabetyp, den Namen der Methode, der in Klammern eingeschlossenen
durch Kommas getrennten Parameterliste und den in geschweiften Klammern
eingeschlossenen Programmrumpf. Im Programmrumpf wird mit dem
Schlüsselwort return angegeben, welches Ergebnisobjekt die
Methode liefert.
Als Beispiel definieren wir eine Klasse, in der es eine
Methode addString gibt,
die den Ergebnistyp String
und zwei Parameter vom Typ String hat:
class StringUtilMethod {
String addStrings(String leftText, String rightText){
return leftText+rightText;
}
}
Methoden und Parameternamen werden per Konvention immer klein geschrieben.
In einer Methode stehen die Felder der Klasse zur VerfügungDas ist
wiederum nicht die volle Wahrheit, wie in Kürze zu sehen sein wird..
Wir können mit den bisherigen Mitteln eine kleine Klasse definieren, die
es erlaubt, Personen zu repräsentieren, so daß die Objekte dieser Klasse
eine Methode haben, um den vollen Namen der Person anzugeben:
class PersonExample1 {
String vorname;
String nachname;
String getFullName(){
return (vorname+" "+nachname);
}
}
Es lassen sich auch Methoden schreiben, die keinen eigentlichen Wert
berechnen, den sie als Ergebnis zurückgeben. Solche Methoden haben keinen
Rückgabetyp. In Java wird dieses gekennzeichnet, indem das
Schlüsselwort void statt eines Typnamens in der Deklaration
steht. Solche
Methoden haben keine return-Anweisung.
Folgende kleine Beispielklasse enthält zwei Methoden zum Setzen neuer Werte
für ihre Felder:
class PersonExample2 {
String vorname;
String nachname;
void setVorname(String newName){
vorname = newName;
}
void setNachname(String newName){
nachname = newName;
}
}
Obige Methoden weisen konkrete Objekte den Feldern des Objektes zu.
Wir haben oben gesehen, wie prinzipiell Objekte einer Klasse
mit dem new-Konstrukt erzeugt
werden. In unserem obigen Beispiel würden wir gerne bei der Erzeugung
eines Objektes gleich konkrete Werte für die Felder mit angeben, um direkt eine
Person mit konkreten Namen erzeugen zu können. Hierzu können Konstruktoren
für eine Klasse definiert werden. Ein Konstruktor kann als eine besondere
Art der Methode betrachtet werden, deren Name der Name der Klasse ist und
für die kein Rückgabetyp spezifiziert wird. So läßt sich ein Konstruktor
spezifizieren, der in unserem Beispiel konkrete Werte für die Felder der
Klasse übergeben bekommt:
class Person1 {
String vorname;
String nachname;
Person1(String derVorname, String derNachname){
vorname = derVorname;
nachname = derNachname;
}
String getFullName(){
return (vorname+" "+nachname);
}
}
Jetzt lassen sich bei der Erzeugung von Objekten des Typs Person
konkrete Werte für die Namen übergeben.
Wir erzeugen ein Personenobjekt mit dem für die entsprechende Klasse
geschriebenen Konstruktor:
class TestePerson1 {
public static void main(String [] _){
new Person1("Nicolo","Paganini");
}
}
Wie man sieht, machen wir mit diesem Personenobjekt noch nichts. Das Programm
hat keine Ausgabe oder Funktion.
Wir kennen bisher die Felder einer Klasse. Wir können ebenso
lokal in einem Methodenrumpf ein Feld deklarieren und benutzen. Solche
Felder werden genutzt, um Objekten für einen relativ kurzen Programmabschnitt
einen Namen zu geben, mit dem wir das Objekt benennen:
class FirstLocalFields{
public static void main(String [] args){
String str1 = "hello";
String str2 = "world";
System.out.println(str1);
System.out.println(str2);
String str3 = str1+" "+str2;
System.out.println(str3);
}
}
Im obigen Programm deklarieren wir drei lokale Felder in einem Methodenrumpf
und weisen ihnen jeweils ein Objekt vom Typ String zu.
Von dem Moment der Zuweisung an steht das Objekt unter dem Namen des Feldes
zur Verfügung.
Wir wissen jetzt, wie Klassen definiert und Objekte einer
Klasse erzeugt werden. Es fehlt uns schließlich noch, die Eigenschaften
eines Objektes anzusprechen. Wenn wir ein Objekt haben, lassen sich
die Eigenschaften dieses Objekts über einen Punkt und
den Namen der Eigenschaften
ansprechen.
Wenn wir also ein Objekt in einem Feld x abgelegt haben
und das Objekt ist vom Typ Person, der ein Feld vorname hat,
so erreichen wir das Objekt, das im Feld vorname gespeichert ist,
durch den Ausdruck: x.vorname.
class GetPersonName{
public static void main (String [] args){
Person1 p = new Person1("August","Strindberg");
String name = p.vorname;
System.out.println(name);
}
}
Ebenso können wir auf den Rückgabewert
einer Methode zugreifen. Wir nennen dieses dann einen Methodenaufruf.
Methodenaufrufe unterscheiden sich syntaktisch darin von Feldzugriffen,
daß eine in runden Klammern eingeschlossene Argumentliste folgt:
class CallFullNameMethod{
public static void main (String [] args){
Person1 p = new Person1("August","Strindberg");
String name = p.getFullName();
System.out.println(name);
}
}Schreiben Sie Klassen, die die Objekte des Bibliotheksystems
repräsentieren können:
Personen mit Namen, Vornamen, Straße, Ort und Postleitzahl.Bücher mit Titel und Autor.Datum mit Tag, Monat und Jahr.Buchausleihe mit Ausleiher, Buch und Datum.
Hinweis: der Typ, der ganze Zahlen in Java bezeichnet, heißt int.
Schreiben Sie geeignete Konstruktoren für diese Klassen.Schreiben Sie für jede dieser Klassen eine
Methode public String toString() mit dem Ergebnistyp.
Das Ergebnis soll eine gute textuelle Beschreibung
des Objektes sein.Sie brauchen noch nicht zu verstehen, warum vor
dem Rückgabetyp noch ein Attribut public steht.Schreiben Sie eine Hauptmethode in einer Klasse Main, in der Sie
Objekte für jede der obigen Klassen erzeugen und die Ergebnisse der
toString-Methode auf den Bildschirm ausgeben. Suchen Sie auf Ihrer lokalen Javainstallation oder im Netz
auf den Seiten von Sun nach
der Dokumentation der Standardklassen von Java. Suchen Sie die
Dokumentation der Klasse String. Testen Sie einige der für
die Klasse String definierten Methoden.
Eigenschaften sind an Objekte gebunden. Es gibt in Java
eine Möglichkeit,
in Methodenrümpfen über das Objekt, für das eine Methode aufgerufen wurde, zu
sprechen. Dieses geschieht mit dem Schlüsselwort this. this
ist zu lesen als: dieses Objekt, in dem du dich gerade befindest.
Häufig wird der this-Bezeichner in Konstruktoren benutzt, um den
Namen des Parameters des Konstruktors von einem Feld zu unterscheiden:
class UseThis {
String aField ;
UseThis(String aField){
this.aField = aField;
}
}
Bisher haben wir Methoden und Felder kennengelernt, die immer
nur als Teil eines konkreten Objektes existiert haben. Es muß auch eine
Möglichkeit geben, Methoden, die unabhängig von Objekten existieren,
zu deklarieren, weil
wir ja mit irgendeiner Methoden anfangen müssen, in der erst Objekte erzeugt
werden können. Hierzu gibt es statische Methoden. Die Methoden, die immer
an ein Objekt gebunden sind, heißen im Gegensatz dazu dynamische Methoden.
Statische Methoden brauchen
kein Objekt, um aufgerufen zu werden. Sie werden exakt so
deklariert wie dynamische Methoden, mit dem einzigen Unterschied, daß
ihnen das Schlüsselwort static vorangestellt wird.
Statische Methoden werden auch in einer Klasse definiert und gehören
zu einer Klasse. Da statische Methoden nicht zu den einzelnen Objekten gehören,
können sie nicht auf dynamische Felder und Methoden der Objekte zugreifen.
class StaticTest {
static void printThisText(String text){
System.out.println(text);
}
}
Statische Methoden werden direkt auf der Klasse, nicht auf einem Objekt
der Klasse aufgerufen.
Auf statische Eigenschaften wird zugegriffen, indem vom Klassennamen per
Punkt getrennt die Eigenschaft aufgerufen wird:
class CallStaticTest {
public static void main(String [] args){
StaticTest.printThisText("hello");
}
}
Ebenso wie statische Methoden gibt es auch statische Felder. Im Unterschied
zu dynamischen Feldern existieren statische Felder genau einmal, nämlich
in der Klasse. Dynamische Felder existieren für jedes Objekt der Klasse.
Statische Eigenschaften nennt man auch
Klassenfelder bzw.Klassenmethoden.
Mit statischen Eigenschaften verläßt man quasi die objektorientierte
Programmierung,
denn diese Eigenschaften sind nicht mehr an Objekte gebunden. Man könnte mit
statischen Eigenschaften Programme schreiben, die niemals ein Objekt erzeugen.
Ergänzen sie jetzt die Klasse Person aus der letzten Aufgabe um ein
statisches Feld letzerVorname mit einer Zeichenkette,
die angeben soll, welchen Vornamen das zuletzt erzeugte Objekt vom
Typ Person hatte. Hierzu müssen Sie im Konstruktor der Klasse Person
dafür sorgen, daß nach der Zuweisung der Objektfelder auch noch das
Feld letzerVorname verändert wird. Testen Sie in einer Testklasse, daß
sich tatsächlich nach jeder Erzeugung einer neuen Person dieses Feld verändert
hat.
Im letzten Abschnitt wurde ein erster Einstieg in die
objektorientierte Programmierung gegeben. Wie zu sehen war, ermöglicht
die objektorientierte Programmierung, das zu lösende Problem in
logische Untereinheiten zu unterteilen, die direkt mit den Teilen der
zu modellierenden Problemwelt korrespondieren.
Die Methodenrümpfe, die die eigentlichen Befehle enthalten, in denen
etwas berechnet werden soll, waren bisher recht kurz. In diesem
Kapitel werden wir Konstrukte kennenlernen, die es ermöglichen, in den
Methodenrümpfen komplexe Berechnungen vorzunehmen. Die in diesem
Abschnitt vorgestellten Konstrukte sind herkömmliche Konstrukte der
imperativen Programmierung und in ähnlicher Weise auch in
Programmiersprachen wie C zu finden.Gerade in diesem
Bereich wollten die Entwickler von Java einen leichten Umstieg von der
C-Programmierung nach Java ermöglichen. Leider hat Java in dieser
Hinsicht auch ein C-Erbe und ist nicht in allen Punkte so sauber
entworfen, wie es ohne diese Designvorgabe wäre.
Bisher haben wir noch überhaupt keine Berechnungen im klassischen
Sinne als das Rechnen mit Zahlen kennengelernt. Java stellt Typen zur
Repräsentation von Zahlen zur Verfügung. Leider sind diese Typen keine
Klassen; d.h.insbesondere, daß auf diesen Typen keine Felder und
Methoden existieren, auf die mit einem Punkt zugegriffen werden kann.
Die im Folgenden vorgestellten Typen nennt man primitive Typen. Sie
sind fest von Java vorgegeben. Im Gegensatz zu Klassen, die der
Programmierer selbst definieren kann, können keine neuen primitiven
Typen definiert werden. Um primitive Typnamen von Klassennamen leicht
textuell unterscheiden zu können, sind sie in Kleinschreibung
definiert worden.
Ansonsten werden primitive Typen genauso
behandelt wie Klassen. Felder können primitive Typen als Typ haben und
ebenso können Parametertypen und Rückgabetypen von Methoden primitive
Typen sein.
Um Daten der primitiven Typen aufschreiben zu können, gibt es jeweils
Literale für die Werte dieser Typen.
In der Mathematik sind wir gewohnt, mit verschiedenen Mengen von Zahlen zu
arbeiten:
natürliche Zahlen: Eine induktiv definierbare Menge
mit einer kleinsten Zahl, so daß es für jede Zahl eine eindeutige
Nachfolgerzahl gibt.ganze Zahlen: Die natürlichen Zahlen erweitert um die
mit einem negativen Vorzeichen behafteten Zahlen, die sich ergeben, wenn man
eine größere Zahl von einer natürlichen Zahl abzieht.rationale Zahlen: Die ganzen Zahlen erweitert um
Brüche, die sich ergeben, wenn man eine Zahl durch eine Zahl teilt, von der
sie kein Vielfaches ist.reelle Zahlen: Die ganzen Zahlen erweitert um
irrationale Zahlen, die sich z.B.aus der Quadratwurzel von Zahlen
ergeben, die nicht das Quadrat einer rationalen Zahl sind. komplexe Zahlen: Die reellen Zahlen erweitert um
imaginäre Zahlen, wie sie benötigt werden, um einen Wurzelwert
für negative Zahlen darzustellen.
Es gilt folgende Mengeninklusion zwischen diesen Mengen:
Da bereits nicht endlich ist, ist keine dieser Mengen endlich.
Da wir nur von einer endlich großen Speicherkapazität ausgehen können, lassen
sich für keine der aus der Mathematik bekannten Zahlenmengen alle Werte in
einem Rechner darstellen. Wir können also schon einmal nur Teilmengen der
Zahlenmengen darstellen.
Von der Hardwareseite stellt sich heute zumeist die folgende Situation dar:
Der Computer hat einen linearen Speicher, der in Speicheradressen unterteilt
ist. Eine Speicheradresse
bezeichnet einen Bereich von 32 Bit. Wir bezeichnen diese als ein Wort. Die
Einheit von 8 Bit wird als Byte bezeichnet ein anderes selten
gebrauchtes Wort aus dem Französischen ist: Oktett. Heutige
Rechner verwalten also in der Regel Dateneinheiten von 32 Bit. Hieraus ergibt
sich die Kardinalität der Zahlenmengen, mit denen ein Rechner als primitive
Typen rechnen kann. Soll mit größeren Zahlenmengen gerechnet werden, so muß
hierzu eine Softwarelösung genutzt werden.
Natürliche Zahlen werden in der Regel durch Zeichenketten von
Symbolen, den Ziffern, eines
endlichen Alphabets dargestellt. Die Größe dieser Symbolmenge
wird als die Basis b der Zahlendarstellung bezeichnet.
Für die Basis b gilt: b > 1. Die Ziffern bezeichnen die
natürlichen Zahlen von 0 bis b-1.
Der Wert der Zahl einer
Zeichenkette an-1a0 berechnet sich für die Basis b nach folgender
Formel:
i=0n-1 ai*bi
= a0*b0 + + an-1*b n-1
Gebräuchliche Basen sind:
2: Dualsystem8: Oktalsystem10: Dezimalsystem16: HexadezimalsystemEine etwas unglückliche Namensgebung aus
der Mischung eines griechischen mit einem lateinischen Wort.
Zur Unterscheidung wird im Zweifelsfalle die Basis einer Zahlendarstellung als
Index mit angegeben.
Die Zahl 10 in Bezug auf unterschiedliche Basen dargestellt:
(10)10=
(A)16=
(12)8=
(1010)2
Darüberhinaus gibt es auch Zahlendarstellungen, die nicht in Bezug auf eine
Basis definiert sind, wie z.B.die römischen Zahlen.
Betrachten wir wieder unsere Hardware, die in Einheiten von 32 Bit agiert, so
lassen sich in einer Speicheradresse durch direkte Anwendung der Darstellung
im Dualsystem die natürlichen
Zahlen von 0 bis 232-1 darstellen.
In vielen Programmiersprachen wie z.B. Java gibt es keinen primitiven
Typen, der eine Teilmenge der natürlichen Zahlen darstellt.Wobei
wir großzügig den Typ char ignorieren. Zumeist wird der
Typ int zur Darstellung ganzer Zahlen benutzt. Hierzu bedarf es einer
Darstellung vorzeichenbehafteter Zahlen in den Speicherzellen des Rechners.
Es gibt mehrere Verfahren, wie in einem dualen System vorzeichenbehaftete
Zahlen dargestellt werden können.
Die einfachste Methode ist, von den n für die Zahlendarstellung zur
Verfügung stehenden Bit eines zur Darstellung des Vorzeichens und die
übrigen n-1 Bit für eine Darstellung im Dualsystem zu nutzen.
In der Darstellung durch Vorzeichen und Betrag werden bei einer
Wortlänge von 8 Bit die Zahlen 10 und -10 durch folgende
Bitmuster repräsentiert: 00001010 und 10001010.
Wenn das
linkeste Bit das Vorzeichen bezeichnet, ergibt sich daraus, daß es zwei
Bitmuster für die Darstellung der Zahl 0
gibt: 10000000 und 00000000.
In dieser Darstellung lassen sich bei einer
Wortlänge n die Zahlen von -2n-1-1 bis 2n-1-1 darstellen.
Die Lösung der Darstellung mit Vorzeichen und Betrag erschwert das Rechnen.
Wir müssen zwei Verfahren bereitstellen: eines zum Addieren und eines zum
Subtrahieren (so wie wir in der Schule schriftliches Addieren und Subtrahieren
getrennt gelernt haben).
Versuchen wir, das gängige Additionsverfahren
für 10 und -10 in der Vorzeichendarstellung anzuwenden, so
erhalten wir:
000010101000101010010100
Das Ergebnis stellt keinenfalls die
Zahl 0 dar, sondern die Zahl -20.
Es läßt sich kein einheitlicher Algorithmus für die
Addition in dieser Darstellung finden.
Ausgehend von der Idee, daß man eine Zahlendarstellung sucht, in der allein
durch das bekannte Additionsverfahren auch mit negativen Zahlen korrekt
gerechnet wird, kann man das Verfahren des Einerkomplements wählen.
Die Idee des Einerkomplements ist, daß für jede Zahl die entsprechende
negative Zahl so dargestellt wird, indem jedes Bit gerade andersherum gesetzt
ist.
Bei einer Wortlänge von 8 Bit werden die
Zahlen 10 und -10 durch folgende Bitmuster
dargestellt: 00001010 und 11110101.
Jetzt können auch negative Zahlen mit dem gängigen Additionsverfahren addiert
werden, also kann die Subtraktion durch ein Additionsverfahren durchgeführt
werden.
000010101111010111111111
Das errechnete Bitmuster stellt die negative Null
dar.
In der Einerkomplementdarstellung läßt sich zwar fein rechnen, wir haben aber
immer noch zwei Bitmuster zur Darstellung der 0. Für eine
Wortlänge n lassen sich auch wieder die Zahlen von -2n-1-1 bis 2n-1-1 darstellen..
Ebenso wie in der Darstellung mit Vorzeichen und Betrag erkennt man in der
Einerkomplementdarstellung am linkesten Bit, ob es sich um eine negative oder
um eine positive Zahl handelt.
Die Zweierkomplementdarstellung verfeinert die Einerkomplementdarstellung, so
daß es nur noch ein Bitmuster für die Null gibt. Im Zweierkomplement wird für
eine Zahl die negative Zahl gebildet, indem zu ihrer
Einerkomplementdarstellung noch 1 hinzuaddiert wird.
Bei einer Wortlänge von 8 Bit werden die
Zahlen 10 und -10 durch folgende Bitmuster
dargestellt: 00001010 und 11110110.
Jetzt können weiterhin auch negative Zahlen mit dem
gängigen Additionsverfahren addiert
werden, also kann die Subtraktion durch ein Additionsverfahren durchgeführt
werden.
000010101111011000000000
Das errechnete Bitmuster stellt die Null
dar.
Die negative Null aus dem Einerkomplement stellt im Zweierkomplement
keine Null dar, sondern die Zahl -1, wie man sich vergegenwärtigen
kann, wenn man von der 1 das Zweierkomplement bildet.
Das Zweierkomplement ist die in heutigen Rechenanlagen gebräuchlichste Form
der Zahlendarstellung für ganze Zahlen. In modernen Programmiersprachen
spiegelt sich dieses in den Wertebereichen primitiver Zahlentypen wieder. So
kennt Java 4 Typen zur Darstellung ganzer Zahlen, die sich lediglich in der
Anzahl der Ziffern unterscheiden. Die Zahlen werden intern als
Zweierkomplement dargestellt.
TypnameLängeWertebereichbyte8 Bit -128=-27 bis 127=27-1short16 Bit-32768=-215 bis 32767=215-1int32 Bit-2147483648=-231 bis 2147483647=232-1long64 Bit-9223372036854775808 bis 9223372036854775807
In der Programmiersprache Java sind die konkreten Wertebereiche für die
einzelnen primitiven Typen in der Spezifikation festgelegt. In anderen
Programmiersprachen wie z.B.C ist dies nicht der Fall. Hier hängt es
vom Compiler und dem konkreten Rechner ab, welchen Wertebereich die
entsprechenden Typen haben.
Es gibt Programmiersprachen wie
z.B.Haskell, in denen es einen Typ
gibt, der potentiell ganze Zahlen von beliebiger Größe darstellen kann.
Starten Sie folgendes Javaprogramm:
class TestInteger {
public static void main(String [] _){
System.out.println(2147483647+1);
System.out.println(-2147483648-1);
}
}
Erklären Sie die Ausgabe.
Gängige Spezifikationen moderner Programmiersprachen sehen einen
ausgezeichneten Wert nan für not a number in
der Arithmetik vor, der ausdrücken soll,
daß es sich bei dem Wert nicht mehr um eine darstellbare Zahl handelt. In der
Arithmetik moderne Prozessoren ist ein solcher zusätzlicher Wert nicht
vorgesehen. Warum eigentlich nicht?! Es sollte doch möglich sein, einen
Prozessor zu bauen, der beim Überlauf des Wertebereichs nicht stillschweigend
das durch den Überlauf entstandene Bitmuster wieder als Zahl interpretiert,
sondern den ausgezeichneten Wert nan als Ergebnis hat. Ein
Programmierer könnte dann entscheiden, wie er in diesem Fall verfahren
möchte. Eine große Fehlerquelle wäre behoben. Warum ist eine solche
Arithmetik noch nicht in handelsüblichen Prozessoren verwirklicht?
Wollen wir Kommazahlen, also Zahlen aus den
Mengen und , im Rechner darstellen,
so stoßen wir auf ein zusätzliches Problem: es gilt dann nämlich nicht mehr,
daß ein Intervall nur endlich viele Werte enthält, wie es für ganze Zahlen
noch der Fall ist. Bei ganzen Zahlen konnten wir immerhin wenigstens alle
Zahlen eines bestimmten Intervalls darstellen. Wir können also nur endlich viele
dieser unendlich vielen Werte in einem Intervall darstellen. Wir werden also
das Intervall diskretisieren.
Vernachlässigen wir für einen Moment jetzt einmal wieder negative Zahlen. Eine
einfache und naheliegende Idee ist, bei einer Anzahl von n Bit, die
für die Darstellung der Kommazahlen zur Verfügung stehen, einen Teil
davon für den Anteil
vor dem Komma und den Rest für den Anteil nach dem Komma zu benutzen. Liegt
das Komma in der Mitte, so können wir eine Zahl aus n Ziffern durch
folgende Formel ausdrücken:
Der Wert der Zahl einer
Zeichenkette an-1an/2,an/2-1a0 berechnet sich für die Basis b nach folgender
Formel:
i=0n-1 aii-n/2
= a0*b-n/2 + + an-1*bn-1-n/2
Wir kennen diese Darstellung aus dem im Alltag gebräuchlichen Zehnersystem.
Für Festkommazahlen ergibt sich ein überraschendes Phänomen. Zahlen, die sich
bezüglich einer Basis darstellen lassen, sind bezüglich einer anderen Basis
nicht darstellbar. So läßt sich schon die sehr einfach zur Basis 10
darstellbare Zahl 0,1 nicht zur Basis 2 als Festkommazahl darstellen,
und umgekehrt können Sie einfach zur Basis 2 als Festkommazahl darstellbare
Zahlen nicht zur Basis 10 darstellen.
Dem Leser wird natürlich nicht entgangen sein, daß wir keine irrationale Zahl
über die Festkommadarstellung darstellen können.
Festkommazahlen spielen in der Rechnerarchitektur kaum eine Rolle. Ein sehr
verbreitetes Anwendungsgebiet für Festkommazahlen sind Währungsbeträge. Hier
interessieren in der Regel nur die ersten zwei oder drei Dezimalstellen nach
dem Komma.
Eine Alternative zu der Festkommadarstellung von Zahlen ist die
Fließkommadarstellung. Während die Festkommadarstellung einen Zahlenbereich
der rationalen Zahlen in einem festen Intervall durch diskrete, äquidistant
verteilte Werte darstellen kann, sind die diskreten Werte in der
Fließkommadarstellung nicht gleich verteilt.
In der Fließkommadarstellung wird eine Zahl durch zwei Zahlen charakterisiert
und ist bezüglich einer Basis b:
die Mantisse für die darstellbaren Ziffern. Die Mantisse
charakterisiert die Genauigkeit der Fließkommazahl.der Exponent, der angibt, wie weit die Mantisse hinter bzw. vor dem
Komma liegt.
Aus Mantisse m, Basis b und Exponent exp ergibt sich
die dargestellte Zahl durch folgende Formel:
z = m * b exp
Damit lassen sich mit Fließkommazahlen sehr große und sehr kleine Zahlen
darstellen. Je größer jedoch die Zahlen werden, desto weiter liegen sie von der
nächsten Zahl entfernt.
Für die Fließkommadarstellung gibt es in Java zwei Zahlentypen, die nach der
Spezifikation des IEEE 754-1985 gebildet werden:
float: 32 Bit Fließkommazahl nach IEEE 754. Kleinste positive
Zahl: 2-149.
Größte positive
Zahl: (2-2-23)*127double: 64 Bit Fließkommazahl nach IEEE 754. Kleinste positive
Zahl: 2-1074.
Größte positive
Zahl: (2-2-52)*1023
Im Format für double steht das erste Bit für das Vorzeichen, die
nächsten 11 Bit markieren den Exponenten und die restlichen 52 Bit kodieren
die Mantisse.
Im Format für float steht das erste Bit für das Vorzeichen, die
nächsten 8 Bit markieren den Exponenten und die restlichen 23 Bit kodieren
die Mantisse.
Bestimmte Bitmuster charakterisieren einen Wert für negative
und positive unbeschränkte Werte (unendlich) sowie Zahlen, Bitmuster, die
charakterisieren, daß es sich nicht mehr um eine Zahl handelt.
Der folgende Test zeigt, daß bei einer Addition von zwei Fließkommazahlen die
kleinere Zahl das Nachsehen hat:
Wie man an der Ausgabe erkennen kann: selbst die Addition der
Zahl 100000 bewirkt keine Veränderung auf einer großen
Fließkommazahl:
java DoubleTest
3.25E202
3.25E-198
3.25E202
3.25E202
sep@linux:~/fh/prog3/examples/src>]]>
Das folgende kleine Beispiel zeigt, inwieweit und für den Benutzer oft auf
überraschende Weise die Fließkommadarstellung zu Rundungen führt:
Das Programm hat die folgende Ausgabe. Insbesondere in der letzten Zeile
fällt auf, daß Addition und anschließende Subtraktion ein und derselben Zahl
nicht die Identität ist. Für Fließkommazahlen gilt
nicht: x + y - y = x.
java Rounded
8.0
88.0
8888.0
88888.0
888888.0
8888888.0
8.8888888E7
8.888889E8
8.8888893E9
8.8888885E10
8.8888889E11
8.8888889E12
0.0
sep@linux:~/fh/prog3/examples/src>]]>
Java hält eine Anzahl numerische primitiver Datentypen bereit.
Ein einfacher Datentyp zur Darstellung ganzer Zahlen ist der Typ:
int. Daten dieses Typs lassen sich durch eine einfache
Ziffernfolge schreiben. Die Literale diesen Typs sind entsprechend
also einfache Ziffernfolgen, denen ein Negationszeichen vorangestellt
sein kann, z.B. 0, 42, 1967, -1000.
In der folgenden Klassen sehen wir Beispiele, wie int als Typ
eines Feldes als Rückgabe- und Parametertyp einer Methode benutzt
wird, sowie ein Beispiel für ein Literal.
class Testint{
int eineGanzeZahl;
Testint(int i){
eineGanzeZahl = i;
}
int getEineGanzeZahl(){
return this.eineGanzeZahl;
}
public static void main(String [] args){
Testint t = new Testint(42);
System.out.println(t.getEineGanzeZahl());
}
}
Eine in der Informatik häufig gebrauchte Unterscheidung ist, ob etwas
wahr oder falsch ist, also eine Unterscheidung zwischen genau zwei
Werten.Wahr oder falsch, eine dritte Möglikchkeit gibt es
nicht. Logiker postulieren dieses als tertium non
datur. Hierzu bedient man sich der Wahrheitswerte aus
der formalen
Logik. Java bietet einen primitiven Typ an, der genau zwei Werte
annehmen kann, den Typ: boolean. Die Bezeichnung ist nach dem
englischen Mathematiker und Logiker George Bool (1815--1869) gewählt
worden.
Entsprechend der zwei möglichen Werte für diesen Typ stellt Java auch
zwei Literale zur Verfügung: true und false.
Bool'sche Daten lassen sich ebenso wie numerische Daten
benutzen. Felder und Methoden können diesen Typ verwenden.
class Testbool{
boolean boolFeld;
Testbool(boolean b){
boolFeld = b;
}
boolean getBoolFeld(){
return this.boolFeld;
}
public static void main(String [] args){
Testbool t = new Testbool(true);
System.out.println(t.getBoolFeld());
t = new Testbool(false);
System.out.println(t.getBoolFeld());
}
}
Wir haben jetzt gesehen, was Java uns für Typen zur Darstellung von
Zahlen zur Verfügung stellt. Jetzt wollen wir mit diesen Zahlen nach
Möglichkeit auch noch rechnen können. Hierzu stellt Java eine feste
Anzahl von Operatoren wie *, -, / etc.zur
Verfügung. Prinzipell gibt es in der Informatik für Operatoren drei
mögliche Schreibweisen:
Präfix: Der Operator wird vor den Operanden geschrieben,
also z.B.(* 2 21). Im ursprünglichen Lisp gab es die
Prefixnotation für Operatoren.
Postfix: Der Operator folgt den Operanden, also
z.B.(21 2 *). Forth und Postscript sind Beispiele von
Sprachen mit Postfixnotation.Infix: Der Operator steht zwischen den Operanden. Dieses
ist die gängige Schreibweise in der Mathematik und für das Auge die
gewohnteste. Aus diesem Grunde bedient sich Java der
Infixnotation: 42 * 2.
Java stellt für Zahlen die vier Grundrechenarten zur Verfügung.
Bei der Infixnotation gelten für die vier Grundrechenarten die
üblichen Regeln der Bindung, nämlich Punktrechnung vor Strichrechnung.
Möchte man diese Regel durchbrechen, so sind Unterausdrücke in
Klammern zu setzen. Folgende kleine Klasse demonstriert den Unterschied:
class PunktVorStrich{
public static void main(String [] args){
System.out.println(2 + 20 * 2);
System.out.println((2 + 20) * 2);
}
}
Wir können nun also Methoden schreiben, die Rechnungen vornehmen. In
der folgenden Klasse definieren wir z.B.eine Methode zum Berechnen
der Quadratzahl der Eingabe:
class Square{
static int square(int i){
return i*i;
}
}
Obige Operatoren rechnen jeweils auf zwei Zahlen und ergeben wieder
eine Zahl als Ergebnis. Vergleichsoperatoren vergleichen zwei Zahlen
und geben einen bool'schen Wert, der angibt, ob der Vergleich wahr
oder falsch ist. Java stellt die folgenden Vergleichsoperatoren zur
Verfügung: <, <=, >, >=, !=, ==.
Für die Gleichheit ist in Java das doppelte
Gleichheitszeichen == zu schreiben,
denn das einfache Gleichheitszeichen ist
bereits für den Zuweisungsbefehl vergeben. Die Ungleichheit wird mit
!= bezeichnet.
Folgende Tests demonstrieren
die Benutzung der Vergleichsoperatoren:
class Vergleich{
public static void main(String[] args){
System.out.println(1+1 < 42);
System.out.println(1+1 <= 42);
System.out.println(1+1 > 42);
System.out.println(1+1 >= 42);
System.out.println(1+1 == 42);
System.out.println(1+1 != 42);
}
}
In der bool'schen Logik gibt es eine ganze Reihe von binären
Operatoren für logische Ausdrücke. Für zwei davon stellt Java auch
Operatoren bereit: && für das logische und () und
|| für das logische oder ().
Zusätzlich kennt Java noch den unären Operator der logischen
Negation $\neg$. Er wird in Java mit ! bezeichnet.
Wie man im folgenden Test sehen kann, gibt es auch unter den
bool'schen Operatoren eine Bindungspräzedenz, ähnlich wie bei der
Regel Punktrechnung vor Strichrechnung. Der Operator &&
bindet stärker als der Operator ||:
class TestboolOperator{
public static void main(String [] args){
System.out.println(true && false);
System.out.println(true || false);
System.out.println(!true || false);
System.out.println(true || true && false);
}
}
In der formalen Logik kennt man noch weitere Operatoren, z.B.die
Implikation $\rightarrow$. Diese Operatoren lassen sich aber durch die
in Java zur Verfügung stehenden Operatoren ausdrücken.
$A\rightarrow B$ entspricht $\neg A\vee B$. Wir können somit eine
Methode schreiben, die die logische Implikation testet:
class TestboolOperator2{
static boolean implication(boolean a, boolean b){
return !a || b;
}
public static void main(String [] args){
System.out.println(implication(true, false));
}
}
Streng genommen kennen wir bisher nur einen Befehl, den
Zuweisungsbefehl, der einem Feld einen neuen Wert
zuweist.Konstrukte mit Operatoren nennt man dagegen
Ausdrücke. In diesem Abschnitt lernen wir weitere Befehle
kennen. Diese Befehle sind in dem Sinne zusammengesetzt, daß sie
andere Befehle als Unterbefehle haben.
Ein häufig benötigtes Konstrukt ist, daß ein Programm abhängig von
einer bool'schen Bedingung sich verschieden verhält. Hierzu stellt
Java die if-Bedingung zur Verfügung. Dem
Schlüsselwort if folgt in Klammern eine bool'sche
Bedingung, anschließend
kommen in geschweiften Klammern die Befehle, die auszuführen
sind, wenn die Bedingung wahr ist. Anschließend kann optional das
Schlüsselwort else folgen mit den Befehlen, die andernfalls
auszuführen sind:
class FirstIf {
static void firstIf(boolean bedingung){
if (bedingung) {
System.out.println("Bedingung ist wahr");
} else {
System.out.println("Bedingung ist falsch");
}
}
public static void main(String [] args){
firstIf(true || false);
}
}
Das if-Konstrukt erlaubt es uns also, Fallunterscheidungen zu
treffen. Wenn in den Alternativen nur ein Befehl steht, so können die
geschweiften Klammern auch fortgelassen werden. Unser Beispiel läßt
sich also auch schreiben als:
class FirstIf2 {
static void firstIf(boolean bedingung){
if (bedingung) System.out.println("Bedingung ist wahr");
else System.out.println("Bedingung ist falsch");
}
public static void main(String [] args){
firstIf(true || false);
}
}
Eine Folge von mehreren if-Konstrukten läßt sich auch
direkt hintereinanderschreiben, so daß eine
Kette von if- und else-Klauseln entsteht:
class ElseIf {
static String lessOrEq(int i,int j){
if (i<10) return "i kleiner zehn";
else if (i>10) return "i größer zehn";
else if (j>10) return "j größer zehn";
else if (j<10) return "j kleiner zehn";
else return "j=i=10";
}
public static void main(String [] args){
System.out.println(lessOrEq(10,9));
}
}
Wenn zuviele if-Bedingungen in einem Programm einander folgen
und ineinander verschachtelt sind, dann wird das Programm schnell
unübersichtlich. Man spricht auch von Spaghetti-code. In
der Regel empfiehlt es sich, in solchen Fällen noch einmal über das
Design nachzudenken, ob die abgefragten Bedingungen sich nicht durch
verschiedene Klassen mit eigenen Methoden darstellen lassen.
Mit der Möglichkeit, in dem Programm abhängig von einer Bedingung
unterschiedlich weiterzurechnen, haben wir theoretisch die Möglichkeit,
alle durch ein Computerprogramm berechenbaren mathematischen
Funktionen zu programmieren. So können wir z.B.eine Methode
schreiben, die für eine Zahl i die Summe
von 1 bis n berechnet:
class Summe{
static int summe(int i){
if (i==1) {
return 1;
}else {
return summe(i-1) + i;
}
}
}
Wir können dieses Programm von Hand ausführen, indem wir den
Methodenaufruf für summe für einen konkreten
Parameter i durch die für diesen Wert zutreffende Alternative der
Bedingungsabfrage ersetzen. Wir kennzeichnen einen solchen
Ersetzungsschritt durch einen Pfeil :
summe(4) summe(4-1)+4 summe(3)+4 summe(3-1)+3+4 summe(2)+3+4 summe(2-1)+2+3+4 summe(1)+2+3+4 1+2+3+4 3+3+4 6+4 10
Wie man sieht, wird für i=4 die Methode summe genau
viermal wieder aufgerufen, bis schließlich die Alternative mit dem
konstanten Rückgabewert 1 zutrifft. Unser Trick war, im
Methodenrumpf die Methode, die wir gerade definieren, bereits zu
benutzen. Diesen Trick nennt man in der
Informatik Rekursion. Mit diesem Trick ist es uns möglich,
ein Programm zu schreiben, bei dessen Ausführung ein bestimmter Teil
des Programms mehrfach durchlaufen wird.
Das wiederholte Durchlaufen von einem Programmteil ist das A und O der
Programmierung. Daher stellen Programmiersprachen in der Regel
Konstrukte zur Verfügung, mit denen man dieses direkt ausdrücken
kann. Die Rekursion ist lediglich ein feiner Trick, dieses zu
bewerkstelligen. In den folgenden Abschnitten lernen wir die
zusammengesetzten Befehle von Java kennen, die es erlauben
auszudrücken, daß ein Programmteil mehrfach zu durchlaufen ist.
Ergänzen Sie ihre Klasse Ausleihe um eine
Methode void verlaengereEinenMonat(), die den Rückgabetermin des
Buches um einen Monat erhöht.
Modellieren und schreiben Sie eine Klasse Counter,
die einen Zähler darstellt. Objekte dieser Klasse sollen folgende
Funktionalität bereitsstellen:
Eine Methode click(), die den internen Zähler um eins
erhöht. Eine Methode reset(), die den Zähler wieder auf den
Wert 0 setzt.
Eine Methode, die den aktuellen Wert des Zählers ausgibt.
Testen Sie Ihre Klasse.
Schreiben Sie mit den bisher vorgestellten Konzepten ein Programm,
das unendlich oft das Wort Hallo auf den Bildschirm ausgibt. Was
beobachten Sie, wenn sie das Programm lange laufen lassen?Schreiben Sie eine Methode, die für eine ganze Zahl die
Fakultät dieser Zahl berechnet. Testen Sie die Methode zunächst mit
kleinen Zahlen, anschließend mit großen Zahlen. Was stellen Sie fest?
Modellieren und schreiben Sie eine Klasse, die ein Bankkonto
darstellt. Auf das Bankkonto sollen Einzahlungen und Auszahlungen
vorgenommen werden können. Es gibt einen maximalen Kreditrahmen. Das
Konto soll also nicht beliebig viel in die Miese gehen
können. Schließlich muß es eine Möglichkeit geben, Zinsen zu berechnen
und dem Konto gutzuschreiben.
Die im letzten Abschnitt kennengelernte Programmierung der
Programmwiederholung durch Rekursion kommt ohne zusätzliche
zusammengesetzte Befehle von Java aus. Da Rekursionen von der
virtuellen Maschine Javas nur bis zu einem gewissen Maße unterstützt
werden, bietet Java spezielle Befehle an, die es erlauben, einen
Programmteil kontrolliert mehrfach zu durchlaufen. Die entsprechenden
zusammengesetzten Befehle heißen Iterationsbefehle. Java kennt drei
unterschiedliche Iterationsbefehle.
Ziel der Iterationsbefehle ist es, einen bestimmten Programmteil
mehrfach zu durchlaufen. Hierzu ist es notwendig, eine Bedingung
anzugeben, für wie lange eine Schleife zu durchlaufen
ist. while-Schleifen in Java haben somit genau zwei Teile:
die Bedingungund den Schleifenrumpf.
Java unterscheidet zwei Arten von while-Schleifen: Schleifen,
für die vor dem Durchlaufen der Befehle des Rumpfes die Bedingung geprüft
wird, und Schleifen, für die nach Durchlaufen des Rumpfes die
Bedingung geprüft wird.
Die vorgeprüften Schleifen haben folgendes Schema in Java:
while (pred)body
pred ist hierbei ein Ausdruck, der zu einem bool'schen Wert
auswertet. body ist eine Folge von Befehlen. Java arbeitet
die vorgeprüfte Schleife ab, indem erst die Bedingung pred
ausgewertet wird. Ist das Ergebnis true, dann wird der
Rumpf (body) der Schleife durchlaufen. Anschließend wird
wieder die Bedingung geprüft. Dieses wiederholt sich so lange, bis die
Bedingung zu false auswertet.
Ein simples Beispiel einer vorgeprüften Schleife ist folgendes Programm, das
die Zahlen von 0 bis 9 auf dem Bildschirm ausgibt:
Mit diesen Mitteln können wir jetzt versuchen, die im letzten
Abschnitt rekursiv geschriebene Methode summe iterativ zu
schreiben:
class Summe2 {
public static int summe(int n){
int erg = 0 ; // Feld für Ergebnis.
int j = n ; // Feld zur Schleifenkontrolle.
while (j>0){ // j läuft von n bis 1.
erg = erg + j; // akkumuliere das Ergebnis.
j = j-1; // verringere Laufzähler.
}
return erg;
}
}
Wie man an beiden Beispielen oben sieht, gibt es oft ein Feld, das zur
Steuerung der Schleife benutzt wird. Dieses Feld verändert innerhalb
des Schleifenrumpfes seinen Wert. Abhängig von diesem Wert wird die
Schleifenbedingung beim nächsten Bedingungstest wieder wahr oder
falsch.
Schleifen haben die unangenehme Eigenschaft, daß sie eventuell nie
verlassen werden. Eine solche Schleife läßt sich minimal wie folgt
schreiben:
Ein Aufruf der Methode bottom startet eine nicht endende
Berechnung.
Häufige Programmierfehler sind inkorrekte Schleifenbedingungen oder
falsch kontrollierte Schleifenvariablen. Das Programm terminiert dann
mitunter nicht. Solche Fehler sind in komplexen Programmen oft schwer
zu finden.
In der zweiten Variante der while-Schleife steht die
Schleifenbedingung syntaktisch nach dem Schleifenrumpf:
do body while (pred)
Bei der Abarbeitung einer solchen Schleife wird entsprechend der
Notation, die Bedingung erst nach der Ausführung des Schleifenrumpfes
geprüft. Am Ende wird also geprüft, ob die Schleife ein weiteres Mal
zu durchlaufen ist. Das impliziert insbesondere, daß der Rumpf
mindestens einmal durchlaufen wird.
Die erste Schleife, die wir für die vorgeprüfte Schleife geschrieben
haben, hat folgende nachgeprüfte Variante:
Man kann sich leicht davon vergewissern, daß die nachgeprüfte Schleife
mindestens einmal durchlaufenDer Javaübersetzer macht kleine
Prüfungen auf konstanten Werten, ob Schleifen jeweils durchlaufen
werden oder nicht terminieren. Deshalb brauchen wir
die Hilfsmethode {\tt falsch()}. wird:
Das syntaktisch aufwendigste Schleifenkonstrukt in Java ist
die for-Schleife.
Wer sich die obigen Schleifen anschaut, sieht, daß sie an drei
verschiedenen Stellen im Programmtext Code haben, der kontrolliert,
wie oft die Schleife zu durchlaufen ist. Oft legen wir ein spezielles
Feld an, dessen Wert die Schleife kontrollieren soll. Dann gibt es im
Schleifenrumpf einen Zuweisungsbefehl, der den Wert dieses Feldes
verändert. Schließlich wird der Wert dieses Feldes in der
Schleifenbedingung abgefragt.
Die Idee der for-Schleife ist, diesen
Code, der kontrolliert, wie oft die Schleife durchlaufen werden
soll, im Kopf der Schleife zu bündeln. Solche Daten sind oft Zähler
vom Typ int, die bis zu einem bestimmten Wert herunter oder
hoch gezählt werden. Später werden wir noch die
Standardklasse Iterator kennenlernen, die benutzt wird, um
durch Listenelemente durchzuiterieren.
Eine for-Schleife hat im Kopf
eine Initialisierung der
relevanten Schleifensteuerungsvariablen (init),ein Prädikat als
Schleifenbedingung (pred)und einen
Befehl, der die Schleifensteuerungsvariable
weiterschaltet (step).
for (init, pred, step)body
Entsprechend sieht unsere jeweilige erste Schleife (die Ausgabe der
Zahlen von 0 bis 9) in der for-Schleifenversion wie folgt aus:
Die Reihenfolge, in der die verschiedenen Teile
der for-Schleife
durchlaufen werden, wirkt erst etwas verwirrend,
ergibt sich aber natürlich aus der Herleitung
der for-Schleife aus der vorgeprüften while-Schleife:
Als erstes wird genau einmal die Initialisierung der Schleifenvariablen
ausgeführt. Anschließend wird die Bedingung geprüft. Abhängig davon
wird der Schleifenrumpf ausgeführt. Als letztes wird die
Weiterschaltung ausgeführt, bevor wieder die Bedingung geprüft wird.
Die nun schon hinlänglich bekannte Methode summe stellt sich
in der Version mit der for-Schleife wie folgt dar:
class Summe3 {
public static int summe(int n){
int erg = 0 ; // Feld für Ergebnis
for (int j = n;j>0;j=j-1){ // j läuft von n bis 1
erg = erg + j; // akkumuliere das Ergebnis
}
return erg;
}
}
Beim Vergleich mit der while-Version erkennt man, wie sich
die Schleifensteuerung im Kopf der for-Schleife nun gebündelt
an einer syntaktischen Stelle befindet.
Die drei Teile des Kopfes einer for-Schleife können auch leer
sein. Dann wird in der Regel an einer anderen Stelle der Schleife
entsprechender Code zu finden sein. So können wir die Summe auch mit
Hilfe der for-Schleife so schreiben, daß die
Schleifeninitialisierung und Weiterschaltung vor der Schleife bzw.im
Rumpf durchgeführt wird:
class Summe4 {
public static int summe(int n){
int erg = 0 ; // Feld für Ergebnis.
int j = n; // Feld zur Schleifenkontrolle
for (;j>0;){ // j läuft von n bis 1
erg = erg + j; // akkumuliere das Ergebnis.
j = j-1; // verringere Laufzähler
}
return erg;
}
}
Wie man jetzt sieht, ist die while-Schleife nur ein
besonderer Fall der for-Schleife.
Obiges Programm ist ein schlechter Programmierstil. Hier wird ohne Not
die Schleifensteuerung mit der eigentlichen Anwendungslogik vermischt.
Java bietet innerhalb des Rumpfes seiner Schleifen zwei Befehle an,
die die eigentliche Steuerung der Schleife durchbrechen. Entgegen der
im letzten Abschnitt vorgestellten Abarbeitung der
Schleifenkonstrukte, führen diese Befehle zum plötzlichen Abbruch des
aktuellen Schleifendurchlaufs.
Der Befehl, um eine Schleife komplett zu verlassen,
heißt break. Der break führt zum sofortigen Abbruch
der nächsten äußeren Schleife.
Der break-Befehl wird in der Regel mit
einer if-Bedingung auftreten.
Mit diesem Befehl läßt sich die Schleifenbedingung auch im Rumpf der
Schleife ausdrücken. Das Programm der Zahlen 0 bis 9 läßt sich
entsprechend unschön auch mit Hilfe des break-Befehls wie
folgt schreiben.
class BreakTest {
public static void main(String [] args){
int i = 0;
while (true){
if (i>9) {break;};
i = i+1;
System.out.println(i);
}
}
}
Gleichfalls läßt sich der break-Befehl in
der for-Schleife anwenden. Dann wird der Kopf
der for-Schleife vollkommen leer:
class ForBreak {
public static void main(String [] args){
int i = 0;
for (;;){
if (i>9) break;
System.out.println(i);
i=i+1;
}
}
}
In der Praxis wird der break-Befehl gerne für besondere
Situationen inmitten einer längeren Schleife benutzt, z.B. für externe
Signale.
Die zweite Möglichkeit, den Schleifendurchlauf zu unterbrechen, ist der
Befehl continue. Diese Anweisung bricht nicht die Schleife
komplett ab, sondern nur den aktuellen Durchlauf. Es wird zum nächsten
Durchlauf gesprungen.
Folgendes kleines Programm druckt mit Hilfe
des continue-Befehls die Zahlen aus, die durch 17 oder 19
teilbar sind:
class ContTest{
public static void main(String [] args){
for (int i=1; i<1000;i=i+1){
if (!(i % 17 == 0 || i % 19 == 0) )
//wenn nicht die Zahl durch 17 oder 19 ohne Rest teilbar ist
continue;
System.out.println(i);
}
}
}
Wie man an der Ausgabe dieses Programms sieht, wird mit dem
Befehl continue der Schleifenrumpf verlassen und die
Schleife im Kopf weiter abgearbeitet. Für die for-Schleife
heißt das insbesondere, daß die Schleifenweiterschaltung der nächste
Ausführungsschritt ist.
Die Entwickler von Java waren sehr konservativ im Entwurf der
Schleifenkonstrukte. Hier schlägt sich deutlich die Designvorgabe nieder,
nicht zu sehr von C entfernt zu sein. So kennt Java zwar drei
Schleifenkonstrukte, doch sind diese relativ primitiv. Alle
Schleifensteuerung muß vom Programmierer selbst codiert werden. Damit
mischt sich Anwendungslogik mit Steuerungslogik.
Es gibt auch keine Schleifenkonstrukte, deren Schleifen leichter als
terminierend zu erkennen sind oder die garantiert terminieren.
Man kann durchaus
kritisieren, daß dieses nicht der aktuellste Stand der Technik ist. Im
Folgenden ein paar kleine Beispiele, wie in anderen
Programmiersprachen Schleifen ausgedrückt werden können.
Die Programmiersprache Bolero der Software AG, die ebenso wie Java
Code für Javas virtuelle Maschine erzeugt, lehnt sich eher an die
aus Datenbankanfragesprachen
bekannte select-Anweisung an.
Die for-Schleife läuft hier über alle Elemente einer Liste
oder eines Iteratorobjektes. Für Zahlen gibt es zusätzlich einfache
syntaktische Konstrukte, um Iteratoren über diese zu erzeugen. Das
Programm, das die Zahlen 0 bis 9 ausgibt, sieht in Bolero wie folgt
aus:
for x in 0 to 9 do
System.out.println(x)
end for
XQuery ist die Anfragesprache für XML-Daten, die derzeit vom W3C
definiert wird. Hier geht man ähnliche Wege wie in Bolero:
{$x}]]>
Da XQuery sich insbesondere mit Sequenzen von XML-Unterdokumenten
beschäftigt und mit Hilfe von XPath-Ausdrücken diese selektieren kann,
lassen sich komplexe Selektionen relativ elegant ausdrücken:
{
for $autor in $buchliste/buch/autor
return $autor
sortBy (./text())
}]]>
In funktionalen Sprachen spielen Listen eine fundamentale
Rolle. Moderne funktionale Sprachen wie ML, Clean oder Haskell bieten
Konstrukte an, die Listen erzeugen oder filtern. Hierzu bedient man
sich einer Notation, die der mathematischen Mengenschreibweise
entlehnt ist. Diese Technik wird list comprehensions
bezeichnet. So gibt es einfache Literale, die Listen erzeugen:
[1,2..10] erzeugt die Liste der Zahlen 1 bis 10[2,4..100] erzeugt die Liste der geraden Zahlen bis
100 erzeugt die unendliche Liste der
Quadratzahlen entsprechend dem mathematischen Ausdruck:
x^2|x\in
Mit diesen Konstrukten läßt sich ein Ausdruck, der die Liste aller
Primzahlen berechnet, in einer Zeile schreiben:
Mit Javaversion 1.5 (Codename Tiger), deren erste Testversion für Ende 2003
öffentlich gemacht werden soll, wird es eine neue Variante
der for-Schleife geben, die die in diesem Abschnitt vorgestellten
Konzepte aus anderen Programmiersprachen zu weiten Teilen realisiert.
Schreiben Sie jetzt die Methode zur Berechnung der Fakultät, indem
Sie eine Iteration und nicht eine Rekursion benutzen.Schreiben Sie eine
Methode static String darstellungZurBasis(int x,int b),
die als Parameter eine
Zahl x und eine zweite Zahl b erhält.
Sie dürfen annehmen, daß x>0 und 1<b<11.
Das Ergebnis soll
eine Zeichenkette vom Typ String sein, in der die
Zahl x zur Basis b dargestellt ist. Testen Sie ihre Methode mit
unterschiedlichen Basen.
Hinweis: Der zweistellige Operator % berechnet den
ganzzahligen Rest einer Division. Bei einem geschickten Umgang mit den
Operatoren %, / und + und einer while-Schleife
kommen Sie mit sechs Zeilen im Rumpf der Methode aus.
Schreiben Sie eine
Methode static int readIntBase10(String str). Diese
Methode soll einen String, der nur aus Ziffern
besteht, in die von ihm repräsentierte Zahl umwandeln. Benutzen sie
hierzu die Methode charAt der String-Klasse,
die es erlaubt, einzelne
Buchstaben einer Zeichenkette zu selektieren.
Wir kennen zwei Möglichkeiten, um einen Programmteil wiederholt
auszuführen: Iteration und Rekursion. Während die Rekursion kein
zusätzliches syntaktisches Konstrukt benötigt, sondern lediglich auf
den Aufruf einer Methode in ihrem eigenen Rumpf beruht, benötigte die
Iteration spezielle syntaktische Konstrukte, die wir lernen mußten.
Javas virtuelle Maschine ist nicht darauf ausgerichtet, Programme mit
hoher Rekursionstiefe auszuführen. Für jeden Methodenaufruf fordert
Java intern einen bestimmten Speicherbereich an, der erst wieder
freigegeben wird, wenn die Methode vollständig beendet wurde.
Dieser Speicherbereich wird als der stack bezeichnet.
Er kann relativ schnell ausgehen. Javas Maschine hat keinen
Mechanismus, um zu erkennen, wann dieser Speicher für den Methodenaufruf
eingespart werden kann.
Folgendes Programm illustriert, wie für eine Iteration über viele
Schleifendurchläufe gerechnet werden kann, die Rekursion hingegen zu
einen Programmabbruch führt, weil nicht genug Speicherplatz auf
dem stack vorhanden ist.
In dieser Aufgabe können Sie zum ersten mal eine
Funktionalität schreiben, die durch eine graphisches
Benutzeroberfläche bedient wird.
Kopieren Sie sich die
Klassen
Dialogue
undString2String
Die Klasse Dialogue definiert eine kleine
graphische Benutzerschnittstelle. Zwei Textflächen und ein
Schaltknopf. Beim Drücken des Schaltknopfs wird der Text aus der oberen
Textfläche ausgelesen, mit dem Text die Methode string2string
des Objektes der Klasse String2String aufgerufen und das
Ergebnis in die untere Textfläche geschrieben.
Übersetzen Sie die Klassen und starten Sie die main-Methode.
Ändern sie die Methode string2string so, daß das
Ergebnis alle Buchstaben in entsprechende Großbuchstaben umwandelt.Hinweis: Die Aufgabe wird fortgesetzt werden.
Schreiben Sie eine Klasse From. Objekte dieser
Klasse sollen die Methode int next() haben. Ausgehend von
einem initialen Anfangswert, soll diese Methode stets eine um eins
höhere Zahl bei jedem Aufruf ausgeben.
Beispiel: Für ein Objekt frm, das
mit new From(42) konstruiert wurde, sollen die Aufrufe
System.out.println(frm.next());
System.out.println(frm.next());
System.out.println(frm.next());
System.out.println(frm.next());
die Zahlen 42, 43, 44, 45 ausgegeben.
Schreiben Sie eine Klasse Sieb. Objekte dieser Klasse
sollen ein Objekt der Klasse From sowie eine
Zahl n enthalten. Auch in dieser Klasse schreiben sie eine
Methode int next(). Das Ergebnis soll die nächste Zahl die
das Objekt vom Typ From ausgibt sein, die nicht
durch n teilbar ist.
Hinweis: der Operator % berechnet den ganzzahligen Rest einer
Division.
Wir kennen mitlerweile eine große Anzahl mächtiger Konstrukte
Javas. Dem aufmerksamen Leser wird aufgefallen sein, daß sich diese
Konstrukte in zwei große Gruppen einteilen lassen: Ausdrücke
und BefehleIn der Literatur findet man mitunter auch den
Ausdruck Anweisung für das, was wir als Befehl
bezeichnen. Befehl bezeichnet dann den Oberbegriff für
Anweisungen und
Ausdrücke. (englisch: expressions und statements).
Ausdrücke berechnen direkt einen Objekt oder ein Datum eines
primitiven Typs. Ausdrücke haben also immer einen Wert. Befehle
hingegen sind Konstrukte, die Felder verändern oder Ausgaben auf dem
Bildschirm erzeugen.
AusdrückeBefehleLiterale: 1, "fgj"Zuweisung: x=1;Operatorausdrücke: 1*42Felddeklaration: int i;Feldzugriffe: obj.myFieldzusammengesetzte
Befehle (if, while, for)Methodenaufrufe
mit Methodenergebnis: obj.toString()Methodenaufrufe
ohne Methodenergebnis: System.out.println("hallo")
Erzeugung neuer
Objekte new MyClass(56)
Ablaufsteuerungsbefehle
wie break, continue, return
Ausdrücke können sich aus Unterausdrücken zusammensetzen. So hat ein
binärer Operatorausdruck zwei Unterausdrücke als Operanden. Hingegen
kann ein Ausdruck niemals einen Befehl enthalten.
Beispiele für Befehle, die Unterbefehle und Unterausdrücke enthalten,
haben wir bereits zu genüge gesehen. An bestimmten Stellen von
zusammengesetzten Befehle müssen Ausdrücke eines bestimmten Typs
stehen: so muß z.B.die Schleifenbedingung ein Ausdruck des
Typs boolean sein.
Die Bedingung kennen wir bisher nur als Befehl in Form
des if-Befehls. Java kennt auch einen Ausdruck, der eine
Unterscheidung auf Grund einer Bedingung macht. Dieser Ausdruck hat
drei Teile: die bool'sche Bedingung und jeweils die positive und
negative Alternative. Syntaktisch wird die Bedingung durch ein
Fragezeichen von den Alternativen und die Alternativen werden mit einem
Doppelpunkt voneinander getrennt:
pred?alt1:alt2
Im Gegensatz zum if-Befehl, in dem die else-Klausel
fehlen kann, müssen im Bedingungsausdruck stets beide Alternativen
vorhanden sein (weil ja ein Ausdruck per Definition einen
Ergebniswert braucht). Die beiden Alternativen brauchen den gleichen
Typ. Dieser Typ ist der Typ des Gesamtausdrucks.
Folgender Code:
class CondExpr {
public static void main(String [] _){
System.out.println(true?1:2);
System.out.println(false?3:4);
}
}
druckt erst die Zahl 1 und dann die Zahl 4.
java CondExpr
1
4
sep@linux:~/fh/prog1/examples/classes>]]>
Wir haben uns bisher wenig Gedanken darüber gemacht, in welcher
Reihenfolge Unterausdrücke von der Javamaschine ausgewertet
werden. Für Befehle war die Reihenfolge, in der sie von Java
abgearbeitet werden, intuitiv sehr naheliegend. Eine Folge von Befehlen
wird in der Reihenfolge ihres textuellen Auftretens abgearbeitet,
d.h. von links nach rechts und von oben nach unten. Zusammengesetze
Befehle wie Schleifen und Bedingungen geben darüberhinaus einen
expliziten Programmablauf vor.
Für Ausdrücke ist eine solche explizite Reihenfolge nicht unbedingt
ersichtlich. Primär interessiert das Ergebnis eines Ausdrucks, z.B.
für den Ausdruck (23-1)*(4-2) interessiert nur das Ergebnis
42. Es ist im Prinzip egal, ob erst 23-1 oder
erst 4-2 gerechnet wird. Allerdings können Ausdrücke in Java
nicht nur einen Wert berechnen, sondern gleichzeitig auch noch
zusätzliche Befehle sozusagen unter der Hand ausführen. In diesem Fall
sprechen wir von Seiteneffekten.
Wir können Seiteneffekte, die eine Ausgabe auf den Bildschirm drucken,
dazu nutzen, zu testen, in welcher Reihenfolge Unterausdrücke
ausgewertet werden. Hierzu schreiben wir eine Subtraktionsmethode mit
einem derartigen Seiteneffekt:
Obige Methode sub berechnet nicht nur die Differenz zweier
Zahlen und gibt diese als Ergebnis zurück, sondern zusätzlich druckt
sie das Ergebnis auch noch auf dem Bildschirm. Dieses Drucken ist bei
einem Audruck, der einen Aufruf der Methode sub enthält, ein
Seiteneffekt. Mit Hilfe solcher Seiteneffekte, die eine Ausgabe
erzeugen, können wir testen, wann ein Ausdruck ausgewertet wird:
Anhand dieses Testprogramms kann man erkennen, daß Java die Operanden
eines Operatorausdrucks von links nach rechts auswertet:
java Operatorauswertung
eine Subtraktion mit Ergebnis: 22 wurde durchgeführt.
eine Subtraktion mit Ergebnis: 2 wurde durchgeführt.
44
sep@swe10:~/fh/prog1/beispiele> ]]>
Ebenso wie für die Operanden eines Operatorausdrucks müssen wir
für die Argumente eines Methodenaufrufs untersuchen, ob, wann und in
welcher Reihenfolge diese ausgewertet werden. Hierzu schreiben wir
eine Methode, die eines ihrer Argumente ignoriert und das zweite als
Ergebnis zurückgibt:
Desweiteren eine Methode, die einen Seiteneffekt hat und einen
konstanten Wert zurückgibt:
Damit läßt sich überprüfen, daß die Methode snd tatsächlich
beide Argumente auswertet, und zwar auch von links nach rechts. Obwohl
ja streng genommen die Auswertung des ersten Arguments zum Berechnen
des Ergebnisses nicht notwendig wäre:
test, in which order arguments are evaluated.
snd(eins("a"),eins("b"));
}}]]>
Spannender noch wird die Frage, ob und wann die Argumente ausgewertet
werden , wenn bestimmte Argumente nicht terminieren. Auch dieses
läßt sich experimentell untersuchen. Wir schreiben eine Methode, die
nie terminiert:
class TerminationTest {
/**
Nonterminating method.
@return never returns anything.
**/
public static int bottom( ){while(wahr()){};return 1; }
/**
Returns the constants 1 and prints its argument.
@param str The String that is printed as side effect.
@return constantly 1.
**/
public static int eins(String str){
System.out.println(str); return 1;
}
/**
Constantly true method.
@return constantly the value true.
**/
public static boolean wahr( ){return true; }
Den Bedingungsausdruck können wir für einen festen Typen der beiden
Alternativen als Methode schreiben:
Ein Test kann uns leicht davon überzeugen, daß der Methodenaufruf mit
dem entsprechenden Bedingungsausdruck im Terminierungsverhalten
nicht äquivalent ist.
public static void main(String [] args){
//test, whether both alternatives of conditional
//expression are evaluated or just one.
//this will terminate:
System.out.println( false?bottom():eins("b") );
//this won't terminate:
System.out.println(wenn( false,bottom(),eins("b") ) );
}
}
Man sieht also, daß eine äquivalente Methode zum Bedingungsausdruck in
Java nicht geschrieben werden kann.
So natürlich uns die in diesem Abschnitt festgestellten
Auswertungsreihenfolgen von Java erscheinen, so sind sie doch nicht
selbstverständlich. Es gibt funktionale Programmiersprachen, die nicht
wie Java vor einem Methodenaufruf erst alle Argumente des Aufrufs
auswerten. Diese Auswertungsstrategie wird dann
als nicht strikt bezeichnet, während man die
Auswertungsstrategie von Java als strikt bezeichnet.
Wir haben die beiden zweistelligen Infixoperatoren für das
logische und&& und das
logische oder|| kennengelernt. Aus der formalen Logik ist
bekannt:
true A = true und false A = false
In bestimmten Fällen läßt sich das Ergebnis einer bool'schen Operation bereits
bestimmen, ohne den zweiten Operanden anzuschauen. Die Frage ist, ob auch
Java in diesen Fällen sich zunächst den linken Operanden anschaut und
nur bei Bedarf noch den rechten Operanden betrachtet. Hierzu können wir ein
Testprogramm schreiben. Wir bedienen uns dabei wieder einer Methode, die nicht
nur einen bool'schen Wert als Ergebnis, sondern auch einen
Seiteneffekt in Form einer Ausgabe auf den Bildschirm hat:
An der Ausgabe dieses Programms läßt sich schließlich erkennen, ob Java
bei der Auswertung auch noch den rechten Operanden betrachtet, obwohl durch den
linken Operanden der Wert des Gesamtausdrucks bereits ermittelt werden kann:
java LazyBool
true
false
sep@linux:~/fh/prog1/examples/classes>]]>
Und tatsächlich bekommen wir keine Ausgabe aus der
Methode booleanExpr. Java wertet also den linken Operanden in diesem
Fall nicht mehr aus. Die beiden bool'schen Operatoren && und || werden in Java nicht strikt ausgewertet.
Zu den beiden logischen binären
Operatoren && und || gibt es zwei Versionen, in
denen das entsprechende Zeichen nicht doppelt
steht: & und |. Sie stehen auch für das
logische und bzw.oder.
Die einfachen Versionen der bool'schen Operatoren haben eine andere
Strategie, wie sie das Ergebnis berechnen. Sie werten zunächst beide
Operanden aus, um dann das Ergebnis aus deren Werten zu berechnen.
Die Strategie, immer erst alle Operanden einer Operation
(entsprechend die Parameter eines Methodenaufrufs) komplett
anzuschauen und auszuwerten, nennt man strikt. Die
gegenteilige Strategie, nur das Notwendigste von den Operanden
auszuwerten, entsprechend nicht-strikt.
Kommt zur nicht-strikten
Strategie noch eine Strategie hinzu, die verhindert, daß Ausdrücke
doppelt ausgewertet werden, so spricht man von einer
faulen (lazy) Auswertung.
Wenn wir jetzt im obigen Testprogramm statt der nicht-strikten Operatoren die
beiden strikten Operatoren benutzen, beobachten wir den Seiteneffekt aus der
Methode booleanExpr:
Und tatsächlich bekommen wir jetzt zusätzliche Ausgaben auf dem Bildschirm:
java StrictBool
in booleanExpr
true
in booleanExpr
false
sep@linux:~/fh/prog1/examples/classes>]]>Für die Lösung dieser Aufgabe gibt es 3 Punkte, die auf die
Klausur angerechnet werden. Voraussetzung hierzu ist, daß die Lösung
mir in der Übung gezeigt und erklärt werden
kann.
In dieser Aufgabe sollen Sie eine Klasse für römische Zahlen entwickeln.
Schreiben Sie eine Klasse Roman. Diese Klasse soll
eine natürliche Zahl
darstellen.
Schreiben Sie für Ihre Klasse Roman einen Konstruktor, der ein
Stringobjekt als Parameter hat. Dieser Stringparameter soll eine römische Zahl
darstellen. Der Konstruktor soll diese Zahl lesen und in einem Feld des
Typs int abspeichern.Implementieren Sie die Methode public String toString() für
Ihre Klasse Roman, die die intern gespeicherte Zahl als römische Zahl
dargestellt zurückibt.Fügen Sie ihrer Klasse Roman die folgenden Methoden für
arithmetische Rechnungen hinzu.
Roman add(Roman other)Roman sub(Roman other)Roman mul(Roman other)Roman div(Roman other)Testen Sie Ihre Klasse Roman.Eines der grundlegendsten Ziele der objektorientierten Programmierung
ist die Möglichkeit, bestehende Programme um neue Funktionalität
erweitern zu können. Hierzu bedient man sich der Vererbung. Bei der
Definition einer neuen Klassen hat man die Möglichkeit, anzugeben, daß
diese Klasse alle Eigenschaften von einer bestehenden Klasse erbt.
Wir haben in einer früheren Übungsaufgabe die Klasse Person
geschrieben:
Wenn wir zusätzlich eine Klasse schreiben wollen, die nicht beliebige
Personen speichern kann, sondern Studenten, die als zusätzliche
Information noch eine Matrikelnummer haben, so stellen wir fest, daß
wir wieder Felder für den Namen und die Adresse anlegen müssen;
d.h.wir müssen die bereits in der Klasse Person zur Verfügung
gestellte Funktionalität ein weiteres Mal schreiben:
Mit dem Prinzip der Vererbung wird es ermöglicht, diese Verdoppelung
des Codes, der bereits für die Klasse Person geschrieben
wurde, zu umgehen.
Wir werden in diesem Kapitel schrittweise eine
Klasse Student entwickeln, die die
Eigenschaften erbt, die wir in der Klasse Person bereits definiert
haben.
Zunächst schreibt man in der Klassendeklaration der
Klasse Student, daß deren Objekte alle Eigenschaften der
Klasse Person erben. Hierzu wird das
Schlüsselwort extends verwendet:
Mit dieser extends-Klausel wird angegeben, daß die Klasse von
einer anderen Klasse abgeleitet wird und damit deren Eigenschaften erbt.
Jetzt brauchen die Eigenschaften, die schon in der
Klasse Person definiert wurden, nicht mehr neu definiert zu werden.
Mit der Vererbung steht ein Mechanismus zur Verfügung, der zwei
primäre Anwendungen hat:
Erweitern: zu den Eigenschaften der Oberklasse werden
weitere Eigenschaften hinzugefügt. Im Beispiel der Studentenklasse
soll das Feld matrikelNummer hinzugefügt werden.Verändern: eine Eigenschaft der Oberklasse wird
umdefiniert. Im Beispiel der Studentenklasse soll die
Methode toString der Oberklasse in ihrer Funktionalität
verändert werden.
Es gibt in Java für eine Klasse immer nur genau eine direkte
Oberklasse. Eine sogenannte multiple Erbung ist in Java nicht
möglich.Dieses ist z.B.in C++ möglich.
Es gibt immer maximal eine extends-Klausel in einer
Klassendefinition.
Unser erstes Ziel der Vererbung war, eine bestehende Klasse um neue
Eigenschaften zu erweitern. Hierzu können wir jetzt einfach mit
der extends-Klausel angeben, daß wir die Eigenschaften einer
Klasse erben. Die Eigenschaften, die wir zusätzlich haben wollen,
lassen sich schließlich wie gewohnt deklarieren:
int matrikelNummer;
Hiermit haben wir eine Klasse geschrieben, die drei Felder
hat: name und adresse, die von der
Klasse Person geerbt werden und zusätzlich das
Feld matrikelNummer. Diese drei Felder können für Objekte der
Klasse Student in gleicher Weise benutzt werden:
String writeAllFields(Student s){
return s.name+" "+s.address+" "+s.matrikelNummer;
}
Ebenso so wie Felder lassen sich Methoden hinzufügen. Z.B.eine
Methode, die die Matrikelnummer als Rückgabewert hat:
int getMatrikelNummer(){
return matrikelNummer;
}Unser zweites Ziel ist, durch Vererbung
eine Methode in ihrem
Verhalten zu verändern. In unserem Beispiel soll die
Methode toString der Klasse Person für
Studentenobjekte so geändert werden, daß das Ergebnis auch die
Matrikelnummer enthält. Hierzu können wir die entsprechende Methode
in der Klasse Student einfach neu schreiben:
public String toString(){
return name + ", " + address
+ " Matrikel-Nr.: " + matrikelNummer;
}
Obwohl Objekte der Klasse Student auch Objekte der
Klasse Person sind, benutzen sie nicht die
Methode toString der Klasse Person, sondern die
neu definierte Version aus der Klasse Student.
Um eine Methode zu überschreiben, muß sie dieselbe Signatur bekommen,
die sie in der Oberklasse hat.
Um für eine Klasse konkrete Objekte zu konstruieren, braucht die
Klasse entsprechende Konstruktoren. In unserem Beispiel soll jedes
Objekt der Klasse Student auch ein Objekt der
Klasse Person sein. Daraus folgt, daß, um ein Objekt der
Klasse Student zu erzeugen, es auch notwendig ist, ein Objekt
der Klasse Person zu erzeugen. Wenn wir also einen
Konstruktor für Student schreiben, sollten wir sicherstellen,
daß mit diesem auch ein gültiges Objekt der
Klasse Person erzeugt wird. Hierzu kann man den Konstruktor
der Oberklasse aufrufen. Dieses geschieht mit dem
Schlüsselwort super. super ruft den Konstruktor der
Oberklasse auf:
Student(String name,String adresse,int nr){
super(name,adresse);
matrikelNummer = nr;
}
}
In unserem Beispiel bekommt der Konstruktor der
Klasse Student alle Daten, die benötigt werden, um ein
Personenobjekt und ein Studentenobjekt zu erzeugen. Als erstes wird
im Rumpf des Studentenkonstruktors der Konstruktor der
Klasse Person aufgerufen. Anschließend wird das zusätzliche
Feld der Klasse Student mit entsprechenden Daten initialisiert.
Ein Objekt der Klasse Student kann wie gewohnt konstruiert
werden:
class TestStudent {
public static void main(String [] _){
Student s
= new Student("Martin Müller","Hauptstraße 2",755423);
System.out.println(s);
}
}
Objekte einer Klasse sind auch ebenso Objekte ihrer Oberklasse. Daher
können sie benutzt werden wie die Objekte ihrer
Oberklasse, insbesondere bei einer Zuweisung. Da in unserem Beispiel
die Objekte der Klasse Student auch Objekte der
Klasse Person sind, dürfen diese auch Feldern des
Typs Person zugewiesen werden:
class TestStudent1{
public static void main(String [] args){
Person p
= new Student("Martin Müller","Hauptstraße",7463456);
}
}
Alle Studenten sind auch Personen.
Hingegen die andere Richtung ist nicht möglich: nicht alle Personen
sind Studenten. Folgendes Programm wird von Java mit einem Fehler
zurückgewiesen:
class StudentError1{
public static void main(String [] args){
Student s
= new Person("Martin Müller","Hauptstraße");
}
}
Die Kompilierung dieser Klasse führt zu folgender Fehlermeldung:
Java weist diese Klasse zurück, weil eine Person nicht ein Student
ist.
Gleiches gilt für den Typ von Methodenparametern. Wenn die Methode
einen Parameter vom Typ Person verlangt, so kann man ihm auch
Objekte eines spezielleren Typs geben, in unserem Fall der
Klasse Student.
class TestStudent2 {
static void printPerson(Person p){
System.out.println(p.toString());
}
public static void main(String [] args){
Student s
= new Student("Martin Müller","Hauptstraße",754545);
printPerson(s);
}
}
Der umgekehrte Fall ist wiederum nicht möglich. Methoden, die als
Parameter Objekte der Klasse Student verlangen, dürfen nicht
mit Objekten einer allgemeineren Klasse aufgerufen werden:
class StudentError2{
static void printStudent(Student s){
System.out.println(s.toString());
}
public static void main(String [] args){
Person p = new Person("Martin Müller","Hauptstraße");
printStudent(p);
}
}
Auch hier führt die Kompilierung zu einer entsprechenden
Fehlermeldung:
StudentError2.java:9: printStudent(Student) in StudentError2
cannot be applied to (Person)
printStudent(p);
^
1 error
Wir haben gesehen, daß wir Methoden überschreiben können. Interessant
ist, wann welche Methode ausgeführt wird. In unserem Beispiel gibt es
je eine Methode toString in der
Oberklasse Person als auch in der
Unterklasse Student.
Welche dieser zwei Methoden wird wann ausgeführt? Wir können dieser
Frage experimentell nachgehen:
class TestLateBinding {
public static void main(String [] args){
Student s = new Student("Martin Müller","Hauptstraße",756456);
Person p1 = new Person("Harald Schmidt","Marktplatz");
System.out.println(s.toString());
System.out.println(p1.toString());
Person p2 = new Student("Martin Müller","Hauptstraße",756456);
System.out.println(p2.toString());
}
}
Dieses Programm erzeugt folgende Ausgabe:
sep@swe10:~/fh/> java TestLateBinding
Martin Müller, Hauptstraße Matrikel-Nr.: 756456
Harald Schmidt, Marktplatz
Martin Müller, Hauptstraße Matrikel-Nr.: 756456
Die ersten beiden Ausgaben entsprechen sicherlich den Erwartungen: es
wird eine Student und anschließend eine Person ausgegeben. Die dritte
Ausgabe ist interessant. Obwohl der Befehl:
System.out.println(p2.toString());
die Methode toString auf einem Feld vom
Typ Person ausführt, wird die Methode toString aus
der Klasse Student ausgeführt. Dieser Effekt entsteht, weil
das Objekt, das im Feld p2 gespeichert wurde, als Student und
nicht als Person erzeugt wurde. Die Idee der Objektorientierung ist,
daß die Objekte die Methoden in sich enthalten. In unserem Fall
enthält das Objekt im Feld p2 seine
eigene toString-Methode. Diese wird ausgeführt.
Der Ausdruck p2.toString() ist also zu lesen als:
Objekt, das in Feld p2 gespeichert ist, führe bitte
deine Methode toString aus.
Da dieses Objekt, auch wenn wir es dem Feld nicht ansehen, ein Objekt
der Klasse Student ist, führt es die entsprechende Methode
der Klasse Student und nicht der Klasse Person aus.
Dieses in Java realisierte Prinzip wird als late binding
bezeichnet.Achtung: late binding funktioniert in
Java nur bei Methoden, nicht bei Feldern.
In dieser Aufgabe sollen Sie eine Gui-Klasse benutzen und ihr eine eigene
Anwendungslogik übergeben.
Gegeben seien die folgenden Javaklassen, wobei Sie die
Klasse Dialogue nicht zu analysieren oder zu verstehen brauchen:
class ButtonLogic {
String getDescription(){
return "in Großbuchstaben umwandeln";
}
String eval(String x){return x.toUpperCase();}
}import javax.swing.*;
import java.awt.event.*;
import java.awt.*;
class Dialogue extends JFrame{
final ButtonLogic logic;
final JButton button;
final JTextField inputField = new JTextField(20) ;
final JTextField outputField = new JTextField(20) ;
final JPanel p = new JPanel();
Dialogue(ButtonLogic l){
logic = l;
button=new JButton(logic.getDescription());
button.addActionListener
(new ActionListener(){
public void actionPerformed(ActionEvent _){
outputField.setText
(logic.eval(inputField.getText().trim()));
}
});
p.setLayout(new BorderLayout());
p.add(inputField,BorderLayout.NORTH);
p.add(button,BorderLayout.CENTER);
p.add(outputField,BorderLayout.SOUTH);
getContentPane().add(p);
pack();
setVisible(true);
}
}class TestDialogue {
public static void main(String [] _){
new Dialogue(new ButtonLogic());
}
}Übersetzen Sie die drei Klassen und starten Sie das Programm.Schreiben Sie eine Unterklasse der Klasse ButtonLogic. Sie
sollen dabei die Methoden getDescription und eval so
überschreiben, daß der Eingabestring in Kleinbuchstaben umgewandelt wird.
Schreiben Sie eine Hauptmethode, in der Sie ein Objekt der
Klasse Dialogue mit einem Objekt Ihrer Unterklasse
von ButtonLogic erzeugen.Schreiben Sie jetzt eine Unterklasse der
Klasse ButtonLogic, so daß Sie im Zusammenspiel mit der
Guiklasse Dialogue ein Programm erhalten, in dem Sie römische Zahlen
in arabische Zahlen umwandeln können. Testen Sie Ihr Programm.Schreiben Sie jetzt eine Unterklasse der
Klasse ButtonLogic, so daß Sie im Zusammenspiel mit der
Guiklasse Dialogue ein Programm erhalten, in dem Sie arabische Zahlen
in römische Zahlen umwandeln können. Testen Sie Ihr Programm.Schreiben Sie jetzt ein Guiprogramm, daß eine Zahl aus ihrer Darstellung
zur Basis 10 in eine Darstellung zur Basis 2 umwandelt. Testen Sie.
Mit dem Prinzip der späten Methodenbindung können wir unsere
ursprüngliche Arbeitshypothese, daß Daten und Programme zwei
unterschiedliche Konzepte sind, etwas aufweichen. Objekte enthalten
in ihren Feldern die Daten und mit ihren Methoden Unterprogramme. Wenn
in einem Methodenaufruf ein Objekt übergeben wird, übergeben wir somit
in dieser Methode nicht nur spezifische Daten, sondern auch
spezifische Unterprogramme. Dieses haben wir uns in der letzten Aufgabe
zunutze gemacht, um
Funktionalität an eine graphische Benutzeroberfläche zu übergeben.
Hierzu betrachten wir
den Konstruktor und die Benutzung der Klasse Dialogue,
die in einer der letzten Aufgaben vorgegeben war:
Dialogue(ButtonLogic l){
logic = l;
button=new JButton(logic.getDescription());
Diese Klasse hat einen Konstruktor, der als Argument ein Objekt des
Typs ButtonLogic erwartet. In dieser Klasse gibt es eine
Methode String eval(String x), die offensichtlich
benutzt wird, um die Funktionalität der graphischen
Benutzerschnittstelle (GUI) zu bestimmen. Ebenso enthält sie eine
Methode String getDescription(), die festlegt, was für eine
Beschriftung für den Knopf benutzt werden soll. Wir übergeben also
Funktionalität in Form von Methoden an die Klasse Dialogue. Je
nachdem, wie in unserer konkreten Unterklasse
von ButtonLogic diese beiden Methoden überschrieben sind, verhält
sich das Guiprogramm.
Die in diesem Abschnitt gezeigte Technik ist eine typische
Javatechnik. Bestehende Programme können erweitert und mit eigener
Funktionalität benutzt werden, ohne daß man die bestehenden Klassen zu
ändern braucht. Wir haben neue Guiprogramme schreiben können, ohne an der
Klasse Dialogue etwas ändern zu müssen, ohne sogar irgendetwas über
Guiprogrammierung zu wissen. Durch die Objektorientierung lassen sich in Java
hervorragend verschiedene Aufgaben trennen und im Team bearbeiten sowie
Softwarekomponenten wiederverwenden.
Vergleichen wir die Methoden toString der
Klassen Person und Student, so sehen wir, daß in der
Klasse Student Code der Oberklasse verdoppelt wurde:
public String toString(){
return name + ", " + address
+ " Matrikel-Nr.: " + matrikelNummer;
}
Der Ausdruck name + ", " + address wiederholt die Berechnung
der toString-Methode aus der Klasse Person. Es
wäre schön, wenn an dieser Stelle die entsprechende Methode aus
der Oberklasse benutzt werden könnte. Auch dieses ist in Java
möglich. Ähnlich, wie der Konstruktor der Oberklasse explizit
aufgerufen werden kann, können auch Methoden der Oberklasse
explizit aufgerufen werden. Auch in diesem Fall ist das
Schlüsselwort super zu benutzen, allerdings nicht in der
Weise, als sei super eine Methode, sondern als sei es ein
Feld, das ein Objekt enthält, also ohne Argumentklammern. Dieses
Feld erlaubt es, direkt auf die Eigenschaften der Oberklasse
zuzugreifen. Somit läßt sich die toString-Methode der
Klasse Student auch wie folgt schreiben:
public String toString(){
return //call toString of super class
super.toString()
//add the Matrikelnummer
+ " Matrikel-Nr.: " + matrikelNummer;
}
Eine berechtigte Frage ist, welche Klasse die Oberklasse für eine
Klasse ist, wenn es keine extends-Klausel gibt. Bisher haben
wir nie eine entsprechende Oberklasse angegeben.
Java hat in diesem Fall eine Standardklasse: Object. Wenn
nicht explizit eine Oberklasse angegeben wird, so ist die
Klasse Object die direkte Oberklasse. Weil
die extends-Relation transitiv ist, ist schließlich jede
Klasse eine Unterklasse der Klasse Object. Insgesamt bilden
alle Klassen, die in Java existieren, eine Baumstruktur, deren Wurzel
die Klasse Object ist.
Es bewahrheitet sich die
Vermutung über objektorientierte Programmierung, daß alles als Objekt
betrachtet wird.Wobei wir nicht
vergessen wollen, daß Daten der primitiven Typen keine Objekte
sind. Es folgt insbesondere, daß jedes Objekt die
Eigenschaften hat, die in der Klasse Object definiert
wurden. Ein Blick in die Java API Documentation zeigt, daß zu diesen
Eigenschaften auch die Methode toString gehört, wie wir sie
bereits einige mal geschrieben haben. Jetzt erkennen wir, daß
wir diese Methode dann überschrieben haben. Auch wenn wir für eine
selbstgeschriebene Klasse die Methode toString nicht
definiert haben, existiert eine solche Methode. Allerdings ist deren
Verhalten selten ein für unsere Zwecke geeignetes.
Die Eigenschaften, die alle Objekte haben, weil sie in der
Klasse Object definiert sind, sind äußerst allgemein. Sobald
wir von einem Object nur noch wissen, daß es vom
Typ Object ist, können wir kaum noch spezifische Dinge mit
ihm anfangen.
Aufgrund einer Schwäche des Typsystems von Java ist man in Java oft
gezwungen, in Methodensignaturen den Typ Object zu
verwenden. Unglücklicher Weise ist die Information, daß ein Objekt vom
Typ Object ist, wertlos. Dieses gilt ja für jedes Objekt. In
kommenden Versionen von Java wird diese Schwäche behoben sein.
Eine weitere Methode, die in der Klasse Object definiert ist,
ist die Methode equals. Sie hat folgende Signatur:
public boolean equals(Object other)
Wenn man diese Methode überschreibt, so kann definiert werden, wann
zwei Objekte einer Klasse als gleich angesehen werden sollen. Für
Personen würden wir gerne definieren, daß zwei Objekte dieser Klasse
gleich sind, wenn sie ein und denselben Namen und ein und dieselbe
Adresse haben. Mit unseren derzeitigen Mitteln läßt sich dieses leider
nicht ausdrücken. Wir würden gerne die equals-Methode wie
folgt überschreiben:
Dieses ist aber nicht möglich, weil für das Objekt other, von
dem wir nur wissen, daß es vom Typ Object ist, keine
Felder name und adresse existieren.
Um dieses Problem zu umgehen, sind Konstrukte notwendig, die von allgemeineren
Typen wieder zu spezielleren Typen führen. Ein solches Konstrukt
lernen wir in den folgenden Abschnitten kennen.
Wie wir oben gesehen haben, können wir zu wenige Informationen über den
Typen eines Objektes haben. Objekte wissen aber selbst, von welcher
Klasse sie einmal erzeugt wurden. Java stellt einen binären Operator
zur Verfügung, der erlaubt, abzufragen, ob ein Objekt zu einer Klasse
gehört. Dieser Operator heißt instanceof. Er hat links ein
Objekt und rechts einen Klassennamen. Das Ergebnis ist ein bool'scher
Wert, der genau dann wahr ist, wenn das Objekt eine Instanz der Klasse
ist.
class InstanceOfTest {
public static void main(String [] str){
Person p1 = new Person("Strindberg","Skandinavien");
Person p2 = new Student("Ibsen","Skandinavien",789565);
if (p1 instanceof Student)
System.out.println("p1 ist ein Student.");
if (p2 instanceof Student)
System.out.println("p2 ist einStudent.");
if (p1 instanceof Person)
System.out.println("p1 ist eine Person.");
if (p2 instanceof Person)
System.out.println("p2 ist eine Person.");
}
}
An der Ausgabe dieses Programms kann man erkennen, daß
ein instanceof-Ausdruck wahr wird, wenn das Objekt ein Objekt
der Klasse oder aber einer Unterklasse der Klasse des zweiten
Operanden ist.
sep@swe10:~/fh> java InstanceOfTest
p2 ist einStudent.
p1 ist eine Person.
p2 ist eine Person.
Im letzten Abschnitt haben wir eine Möglichkeit kennengelernt, zu
fragen, ob ein Objekt zu einer bestimmten Klasse gehört. Um ein Objekt
dann auch wieder so benutzen zu können, daß es zu dieser Klasse
gehört, müssen wir diesem Objekt diesen Typ erst wieder zusichern. Im
obigen Beispiel haben wir zwar erfragen können, daß das
in Feld p2 gespeicherte Objekt nicht nur eine Person, sondern
ein Student ist; trotzdem können wir noch nicht p2 nach
seiner Matrikelnummer fragen. Hierzu müssen wir erst zusichern, daß
das Objekt den Typ Student hat.
Eine Typzusicherung in Java wird gemacht, indem dem entsprechenden
Objekt in Klammer der Typ vorangestellt wird, den wir ihm zusichern
wollen:
class CastTest {
public static void main(String [] str){
Person p = new Student("Ibsen","Skandinavien",789565);
if (p instanceof Student){
Student s = (Student)p;
System.out.println(s.matrikelNummer);
}
}
}
Die Zeile s = (Student)p; sichert erst dem Objekt im
Feld p zu, daß es ein Objekt des Typs Student ist,
so daß es dann als Student benutzt werden kann. Wir haben den
Weg zurück vom Allgemeinen ins Spezifischere
gefunden. Allerdings ist dieser Weg gefährlich. Eine
Typzusicherung kann fehlschlagen:
class CastError {
public static void main(String [] str){
Person p = new Person("Strindberg","Skandinavien");
Student s = (Student)p;
System.out.println(s.matrikelNr);
}
}
Dieses Programm macht eine Typzusicherung des
Typs Student auf ein Objekt, das nicht von diesem Typ ist.
Es kommt in diesem Fall zu einen Laufzeitfehler:
sep@swe10:~/fh> java CastError
Exception in thread "main" java.lang.ClassCastException: Person
at CastError.main(CastError.java:4)
Die Fehlermeldung sagt, daß wir in Zeile 4 des Programms eine
Typzusicherung auf ein Objekt des Typs Person vornehmen, die
fehlschlägt.
Will man solche Laufzeitfehler verhindern, so ist man auf der sicheren
Seite, wenn eine Typzusicherung
nur dann gemacht wird, nachdem man sich mit
einem instanceof-Ausdruck davon überzeugt hat, daß das Objekt
wirklich von dem Typ ist, den man ihm zusichern will.
Mit den jetzt vorgestellten Konstrukten können wir eine Lösung der
Methode equals für die Klasse Person mit der
erwarteten Funktionalität schreiben:
Nur, wenn das zu vergleichende Objekt auch vom Typ Person ist
und den gleichen Namen und die gleiche Adresse hat, dann sind zwei
Personen gleich.
Mit dem Prinzip der Vererbung können wir die
Klassen From und Sieb aus der letzten Aufgabe so
ändern, daß wir mit ihnen ein Programm schreiben können, das alle
Primzahlen erzeugen kann.
Ändern sie die Klasse Sieb der letzten Aufgabe so ab,
daß Sie eine Unterklasse der Klasse From erhalten.Sofern sie in der ersten Unteraufgabe eine korrekte Lösung
haben, druckt folgendes Programm alle Primzahlen. Testen sie dieses.
class PrintPrim{
/**
Prints prime numbers on screen.
**/
static public void main(String [] args){
//initial sieve: starts with all numbers greater 1
From sieb = new From(2);
while (true){
//get the first number of new sieve
int i = sieb.next();
//print it
System.out.println(i);
//create a new sieve, which deletes all multiples of i
sieb = new Sieb(sieb,i);
}//while
}
}Lösen Sie die Aufgabe 8 jetzt, indem Sie eine Unterklasse der
Klasse String2String schreiben.In dieser Aufgabe sollen Sie die Klasse Counter aus
einer der vorhergehenden Aufgaben in ein Gui einbinden. Laden Sie sich
hierzu die Klasse CounterGUI.java
vom Netz und kompilieren Sie diese mit Ihrer Klasse Counter. Passen
Sie gegebenenfalls Ihre Klasse Counter an.
Die bisher kennengelernten Javakonstrukte bilden einen soliden Kern,
der genügend programmiertechnische Mittel zur Verfügung stellt, um die
gängigsten Konzepte der Informatik umzusetzen. In diesem Kapitel
werden wir keine neuen Javakonstrukte kennenlernen, sondern mit den bisher
bekannten Mitteln die häufigsten in der Informatik
gebräuchlichen Datentypen und auf ihnen anzuwendende Algorithmen
erkunden.
Eine der häufigsten Datenstrukturen in der Programmierung sind
Sammlungstypen. In fast jedem nichttrivialen Programm wird es Punkte
geben, an denen eine Sammlung mehrerer Daten gleichen Typs anzulegen
sind. Eine der einfachsten Strukturen, um Sammlungen anzulegen, sind
Listen. Da Sammlungstypen oft gebraucht werden, stellt Java
entsprechende Klassen als Standardklassen zur Verfügung. Bevor wir uns
aber diesen bereits vorhandenen Klassen zuwenden, wollen wir in
diesem Kapitel Listen selbst
spezifizieren und programmieren.
Wir werden Listen als abstrakten Datentyp formal spezifizieren. Ein abstrakter
Datentyp (ADT) wird spezifiziert über eine endliche Menge von Methoden.
Hierzu wird
spezifiziert, auf welche Weise Daten eines ADT konstruiert werden
können. Dazu werden entsprechende Konstruktormethoden spezifiziert. Dann
wird eine Menge von Funktionen definiert, die wieder Teile aus den
konstruierten Daten selektieren können. Schließlich werden noch Testmethoden
spezifiziert, die angeben, mit welchem Konstruktor ein Datum erzeugt
wurde.
Der Zusammenhang zwischen Konstruktoren und Selektoren sowie zwischen den
Konstruktoren und den Testmethoden wird in Form von Gleichungen spezifiziert.
Der Trick, der angewendet wird, um abstrakte Datentypen wie Listen zu
spezifizieren, ist die Rekursion. Das Hinzufügen eines weiteren
Elements zu einer Liste wird dabei als das Konstruieren einer neuen
Liste aus der Ursprungsliste und einem weiteren Element
betrachtet. Mit dieser Betrachtungsweise haben Listen eine rekursive
Struktur: eine Liste besteht aus dem zuletzt vorne angehängten neuen
Element, dem sogenannten Kopf der Liste, und aus der alten Teilliste,
an die dieses Element angehängt wurde, dem sogenannten Schwanz der
Liste. Wie bei jeder rekursiven Struktur bedarf es eines Anfangs der
Definition. Im Falle von Listen wird dieses durch die Konstruktion
einer leeren Liste spezifiziert.Man vergleiche es mit der
Definition der natürlichen Zahlen: die 0 entspricht der leeren
Liste, der Schritt von n nach n+1 dem Hinzufügen eines neuen
Elements zu einer Liste.
Abstrakte Datentypen wie Listen lassen sich durch
ihre Konstruktoren spezifizieren. Die
Konstruktoren geben an, wie Daten des entsprechenden Typs konstruiert
werden können.
In dem Fall von Listen bedarf es nach
den obigen Überlegungen zweier Konstruktoren:
einem Konstruktor für neue Listen, die noch leer sind. einem Konstruktor, der aus einem Element und einer bereits
bestehenden Liste eine neue Liste konstruiert, indem an die
Ursprungsliste das Element angehängt wird.
Wir benutzen in der Spezifikation eine mathematische Notation der
Typen von Konstruktoren.Entgegen der Notation in Java, in der
der Rückgabetyp kurioser Weise vor den Namen der Methode geschrieben
wird. Dem Namen des Konstruktors folgt dabei mit einem
Doppelpunkt abgetrennt der Typ. Der Ergebnistyp wird von den
Parametertypen mit einem Pfeil getrennt.
Somit lassen sich die Typen der zwei Konstruktoren für Listen wie
folgt spezifizieren:
Empty: () ListCons: (Object,List) List
Die Selektoren können wieder auf die einzelnen
Bestandteile der Konstruktion zurückgreifen.
Der Konstruktor Cons hat zwei Parameter. Für Cons-Listen
werden zwei
Selektoren spezifiziert, die jeweils einen dieser beiden Parameter
wieder aus der Liste selektieren. Die Namen dieser beiden Selektoren
sind traditioneller Weise head und tail.
head: (List)Objecttail: (List)List
Der funktionale Zusammenhang von Selektoren und Konstruktoren läßt
sich durch folgende Gleichungen spezifizieren:
head(Cons(x,xs)) x
tail(Cons(x,xs)) xs
Um für Listen Algorithmen umzusetzen, ist es notwendig, unterscheiden
zu können, welche Art der beiden Listen vorliegt: die leere Liste oder
eine Cons-Liste. Hierzu bedarf es noch einer Testmethode, die
mit einem bool'schen Wert als Ergebnis angibt, ob es sich bei der
Eingabeliste um die leere Liste handelte oder nicht. Wir wollen diese
Testmethode isEmpty nennen. Sie hat folgenden Typ:
isEmpty: Listboolean
Das funktionale Verhalten der Testmethode läßt sich durch folgende
zwei Gleichungen spezifizieren:
isEmpty(Empty()) true
isEmpty(Cons(x,xs)) false
Somit ist alles spezifiziert, was eine Listenstruktur ausmacht. Listen
können konstruiert werden, die Bestandteile einer Liste wieder einzeln
selektiert und Listen können nach der Art ihrer Konstruktion
unterschieden werden.
Allein diese fünf Funktionen beschreiben den ADT der
Listen. Wir können aufgrund dieser Spezifikation Algorithmen für Listen
schreiben.
Und ebenso läßt sich durch zwei Gleichungen spezifizieren, was die Länge einer
Liste ist:
length(Empty())0
length(Cons(x,xs))1+length(xs)
Mit Hilfe dieser Gleichungen läßt sich jetzt schrittweise die Berechnung einer
Listenlänge auf Listen durchführen. Hierzu benutzen wir die Gleichungen als
Ersetzungsregeln. Wenn ein Unterausdruck in der Form der linken Seite
einer Gleichung gefunden wird, so kann diese durch die entsprechende rechte
Seite ersetzt werden. Man spricht bei so einem Ersetzungsschritt von einem
Reduktionsschritt.
Wir errechnen in diesem Beispiel die Länge einer Liste, indem wir die obigen
Gleichungen zum Reduzieren auf die Liste anwenden:
length(Cons(a,Cons(b,Cons(c,Empty())))) 1+length(Cons(b,Cons(c,Empty()))) 1+(1+length(Cons(c,Empty())))
1+(1+(1+length(Empty())))
1+(1+(1+0))
1+(1+1)
1+2 3
Wir können mit einfachen Gleichungen spezifizieren, was wir unter dem letzten
Element einer Liste verstehen.
last(Cons(x,Empty()))) x
last(Cons(x,xs)))last(xs)
Auch die Funktion last können wir von Hand auf einer Beispielliste
einmal per Reduktion ausprobieren:
last(Cons(a,Cons(b,Cons(c,Empty())))) last(Cons(b,Cons(c,Empty()))) last(Cons(c,Empty())) c
Die folgenden Gleichungen spezifizieren, wie zwei Listen aneinandergehängt
werden:
concat(Empty(),ys)ys
concat(Cons(x,xs),ys)Cons(x,concat(xs,ys))
Auch diese Funktion läßt sich beispielhaft mit der Reduktion einmal
durchrechnen:
concat(Cons(i,Cons(j,Empty())),Cons(a,Cons(b,Cons(c,Empty())))) Cons(i,concat(Cons(j,Empty()),Cons(a,Cons(b,Cons(c,Empty())))))
Cons(i,Cons(j,concat(Empty(),Cons(a,Cons(b,Cons(c,Empty()))))))
Cons(i,Cons(j,Cons(a,Cons(b,Cons(c,Empty())))))
Listen lassen sich auch sehr schön graphisch visualisieren. Hierzu wird jede
Liste durch eine Schachtel mit zwei Feldern dargestellt. Von diesen beiden
Feldern gehen Pfeile aus. Der erste Pfeil zeigt auf das erste Element der
Liste, dem head, der zweite Pfeil zeigt auf die Schachtel, die für
den Restliste steht dem tail. Wenn eine Liste leer ist, so gehen
keine Pfeile von der Schachtel aus, die sie repräsentiert.
Die Liste Cons(a,Cons(b,Cons(c,Empty()))) hat somit die
Schachtel- und Zeigerr- Darstellung aus Abbildung .
In der Schachtel- und Zeiger-Darstellung läßt sich sehr gut verfolgen, wie
bestimmte Algorithmen auf Listen dynamisch arbeiten.
Wir können die schrittweise Reduktion der Methode concat in der
Schachtel- und Zeiger-Darstellung gut nachvollziehen:
Abbildung zeigt die Ausgangssituation. Zwei Listen sind
dargestellt. Von einer Schachtel, die wir als die Schachtel der
Funktionsanwendung
von concat markiert haben, gehen zwei Zeiger aus. Der erste auf das
erste Argument, der zweite auf das zweite Argument der Funktionsanwendung.
Abbildung zeigt die Situation, nachdem die
Funktion concat einmal reduziert wurde. Ein neuer Listenknoten wurde
erzeugt. Dieser zeigt auf das erste Element der ursprünglich
ersten Argumentliste. Der zweite zeigt auf den rekursiven Aufruf der
Funktion concat, diesmal mit der Schwanzliste des ursprünglich ersten Arguments.
Abbildung zeigt die Situation nach dem zweiten
Reduktionsschritt. Ein weiterer neuer Listenknoten ist entstanden und ein
neuer Knoten für den rekursiven Aufruf ist entstanden.
Abbildung zeigt die endgültige Situation. Der letzte
rekursive Aufruf von concat hatte als erstes Argument eine leere
Liste. Deshalb wurde kein neuer Listenknoten erzeugt, sondern lediglich der
Knoten für die Funktionsanwendung gelöscht. Man beachte, daß die beiden
ursprünglichen Listen noch vollständig erhalten sind. Sie wurden nicht
gelöscht. Die erste Argumentliste wurde quasi kopiert. Die zweite
Argumentliste teilen sich gewisser Maßen die neue Ergebnisliste der
Funktionsanwendung und die zweite ursprüngliche Argumentliste.
Java kennt keine direkte Unterstützung für ADTs, die nach obigen Prinzip
spezifiziert werden. Es gibt jedoch eine Javaerweiterung
namens Pizza, die eine solche
Unterstützung eingebaut hat. Da wir aber nicht
auf Pizza zurückgreifen wollen, bleibt uns nichts anderes, als Listen
in Java zu implementieren.
Nachdem wir im letzten Abschnitt formal spezifiziert haben, wie Listen
konstruiert werden, wollen wir in diesem Abschnitt betrachten, wie
diese Spezifikation geeignet mit unseren programmiersprachlichen
Mitteln modelliert werden kann, d.h.wie viele und was für Klassen
werden benötigt. Wir werden in den folgenden zwei Abschnitten zwei
alternative Modellierungen der Listenstruktur angeben.
Laut Spezifikation gibt es zwei Arten von Listen: leere Listen und
Listen mit einem Kopfelement und einer Schwanzliste. Es ist
naheliegend, für diese zwei Arten von Listen je eine eigene Klasse
bereitzustellen, jeweils eine Klasse für leere Listen und eine
für Cons-Listen. Da beide Klassen zusammen einen Datentyp Liste
bilden sollen, sehen wir eine gemeinsame Oberklasse dieser zwei
Klassen vor. Wir erhalten die Klassenhierarchie in
Abbildung:.
Wir haben uns in diesem Klassendiagramm dazu entschieden, daß die
Selektormethoden für alle Listen auch für leere Listen zur Verfügung
stehen. Es ist in der formalen Spezifikation nicht angegeben worden,
was in dem Fall der Methoden head und tail auf leere
Listen als Ergebnis erwartet wird. Wir können hier Fehler geben oder
aber bestimmte ausgezeichnete Werte als Ergebnis zurückgeben.
Eine alternative Modellierung der Listenstruktur besteht aus nur genau
einer Klasse. Diese Klasse braucht hierzu aber zwei Konstruktoren. Wie
in vielen objektorientierten Sprachen ist es in Java auch möglich, für
eine Klasse mehrere Konstruktoren mit unterschiedlichen Parametertypen
zu schreiben. Wir können also eine Klasse List modellieren,
die zwei Konstruktoren hat: einen mit keinem Parameter und einen mit
zwei Parametern.
Die obigen beiden Modellierungen der Listen lassen sich jetzt direkt
in Javacode umsetzen.
Die zusammenfassende Oberklasse der Listenstruktur List
stellt für die
Listenmethoden head, tail und isEmpty jeweils
eine prototypische Implementierung zur Verfügung. Die
Methode isEmpty setzen wir in unserer Umsetzung standardmäßig
auf false.
class List {
boolean isEmpty(){return false;}
Object head(){return null;}
List tail(){return null;}
Die Methoden tail und head können nur für nichtleere
Listen Objekte zurückgeben. Daher haben wir uns für die prototypischen
Implementierung in der Klasse List dazu entschieden, den
Wert null zurückzugeben. null steht in Java für das
Fehlen eines Objektes. Der Versuch, auf Felder und Methoden
von null zuzugreifen, führt in Java zu einem Fehler.
Für die Klasse List schreiben wir keinen Konstruktor. Wir
wollen diese Klasse nie direkt instanziieren, d.h.nie einen
Konstruktor für die Klasse List aufrufen.In diesem
Fall generiert Java automatisch einen Konstruktor ohne Argumente, doch
dazu mehr an anderer Stelle.
Die Klasse, die die leere Liste darstellt, ist relativ leicht
abzuleiten. Wir stellen einen Konstruktor zur Verfügung und
überschreiben die Methode isEmpty.
class Empty extends List {
Empty(){}
boolean isEmpty(){return true;}
}
Schließlich ist noch die Klasse Cons zu codieren. Diese
Klasse benötigt nach unserer Modellierung zwei Felder, in denen Kopf
und Schwanz der Liste abgespeichert werden können.
Die Methoden head und tail werden so überschrieben,
daß sie entsprechend den Wert eines dieser Felder zurückgeben. Der
Konstruktor initialisiert diese beiden Felder.
class Cons extends List{
Object hd;
List tl ;
Cons(Object x,List xs){
hd = x;
tl = xs;
}
Object head(){
return hd;
}
List tail(){
return tl;
}
Damit ist die Listenstruktur gemäß unserer formalen Spezifikation
vollständig implementiert.
Für Algorithmen auf Listen werden wir nur
die zwei Konstruktoren, die zwei Selektoren und die
Testmethode isEmpty benutzen. Folgendes kleine Testprogramm
konstruiert eine Liste mit drei Elementen des
Typs String. Anschließend folgen einige Tests für die
Selektormethoden:
class TestFirstList {
public static void main(String [] args){
//Konstruktion einer Testliste
List xs = new Cons("friends",
new Cons("romans",
new Cons("countrymen",
new Empty())));
//Zugriffe auf einzelne Elemente
System.out.println("1. Element: "+xs.head());
System.out.println("2. Element: "+xs.tail().head());
System.out.println("3. Element: "+xs.tail().tail().head());
//Test für die Methode isEmpty()
if (xs.tail().tail().tail().isEmpty()){
System.out.println("leere Liste nach drittem Element.");
}
//Ausgabe eines null Wertes
System.out.println(xs.tail().tail().tail().head());
}
}
Tatsächlich bekommen wir für unsere Tests die erwartete Ausgabe:
java TestFirstList
1. Element: friends
2. Element: romans
3. Element: countrymen
leere Liste nach drittem Element.
null
sep@linux:~/fh/prog1/examples/classes>]]>
In der zweiten Modellierung haben wir auf eine Klassenhierarchie
verzichtet. Was wir in der ersten Modellierung durch die verschiedenen Klassen
mit verschieden überschriebenen Methoden ausgedrückt haben, muß in der
Modellierung in einer Klasse über ein bool'sches Feld vom
Typ boolean ausgedrückt werden.
Die beiden unterschiedlichen
Konstruktoren setzen ein bool'sches Feld empty, um
für das neu konstruierte
Objekt zu markieren, mit welchem Konstruktor es konstruiert wurde.
Die Konstruktoren setzen entsprechend die Felder, so daß die
Selektoren diese nur auszugeben brauchen.
class Li {
boolean empty = true;
Object hd;
Li tl;
Li (){}
Li (Object x,Li xs){
hd = x;
tl = xs;
empty = false;
}
boolean isEmpty() {return empty;}
Object head(){return hd;}
Li tail(){return tl;}
Zum Testen dieser Listenimplementierung sind nur die
Konstruktoraufrufe bei der Listenkonstruktion zu ändern. Da wir nur
noch eine Klasse haben, gibt es keine zwei Konstruktoren mit
unterschiedlichen Namen, sondern beide haben denselben Namen:
class TestFirstLi {
public static void main(String [] args){
//Konstruktion einer Testliste
Li xs = new Li("friends",
new Li("romans",
new Li("countrymen",
new Li())));
//Zugriffe auf einzelne Elemente
System.out.println("1. Element: "+xs.head());
System.out.println("2. Element: "+xs.tail().head());
System.out.println("3. Element: "+xs.tail().tail().head());
//Test für die Methode isEmpty()
if (xs.tail().tail().tail().isEmpty()){
System.out.println("leere Liste nach drittem Element.");
}
//Ausgabe eines null Wertes
System.out.println(xs.tail().tail().tail().head());
}
}
Wie man aber sieht, ändert sich an der Benutzung von Listen nichts im
Vergleich zu der Modellierung mittels einer Klassenhierarchie.
Die Ausgabe ist ein und dieselbe für beide Implementierungen:
java TestFirstLi
1. Element: friends
2. Element: romans
3. Element: countrymen
leere Liste nach drittem Element.
null
sep@linux:~/fh/prog1/examples/classes>]]>
Mit der formalen Spezifikation und schließlich Implementierung von
Listen haben wir eine Abstraktionsebene eingeführt. Listen sind für
uns Objekte, für die wir genau die zwei Konstruktormethoden, zwei
Selektormethoden und eine Testmethode zur Verfügung haben. Unter
Benutzung dieser fünf Eigenschaften der Listenklasse können wir jetzt
beliebige Algorithmen auf Listen definieren und umsetzen.
Eine interessante Frage bezüglich Listen ist die nach ihrer Länge. Wir
können die Länge einer Liste berechnen, indem wir durchzählen, aus
wievielen Cons-Listen eine Liste besteht. Wir haben bereits die
Funktion length durch folgende zwei Gleichungen spezifiziert.
length(Empty()) 0
length(Cons(x,xs)) 1 + length(xs)
Diese beiden Gleichungen lassen sich direkt in Javacode umsetzen.
Die zwei Gleichungen ergeben genau zwei durch eine if-Bedingung zu
unterscheidende Fälle in der Implementierung. Wir
können die Klassen List und Li um folgende
Methode length ergänzen:
int length(){
if (isEmpty())return 0;
return 1+tail().length();
}
In den beiden Testklassen läßt sich ausprobieren, ob die
Methode length entsprechend der Spezifikation
funktioniert. Wir können in der main-Methode einen Befehl
einfügen, der die Methode length aufruft:
class TestLength {
static Li XS = new Li("friends",new Li("romans",
new Li("countrymen",new Li())));
public static void main(String [] args){
System.out.println(XS.length());
}
}
Und wie wir bereits schon durch Reduktion dieser Methode von Hand ausgerechnet
haben, errechnet die Methode length tatsächlich die Elementanzahl der
Liste:
java TestLength
3
sep@linux:~/fh/prog1/examples/classes>]]>
Im Fall der Klassenhierarchie können wir auch
die if-Bedingung der Methode length dadurch
ausdrücken, daß die beiden Fälle sich in den unterschiedlichen Klassen
für die beiden unterschiedlichen Listenarten befinden. In der
Klasse List kann folgende prototypische Implementierung
eingefügt werden:
int length(){return 0;}
}
In der Klasse Cons, die ja Listen mit einer Länge größer 0
darstellt, ist dann die Methode entsprechend zu überschreiben:
int length(){return 1+tail().length();}
}
Auch diese Längenimplementierung können wir testen:
class TestListLength {
static List XS = new Cons("friends",new Cons("romans",
new Cons("countrymen",new Empty())));
public static void main(String [] args){
System.out.println(XS.length());
}
}
An diesem Beispiel ist gut zu sehen, wie durch die Aufsplittung in
verschiedene Unterklassen beim Schreiben von
Methoden if-Abfragen verhindert werden können. Die verschiedenen
Fälle einer if-Abfrage finden sich dann in den
unterschiedlichen Klassen realisiert. Der Algorithmus ist in diesem
Fall auf verschiedene Klassen aufgeteilt.
Die beiden obigen Implementierungen der
Methode length sind rekursiv. Im Rumpf der Methode wurde sie
selbst gerade wieder aufgerufen. Natürlich kann die Methode mit den
entsprechenden zusammengesetzten Schleifenbefehlen in Java auch
iterativ gelöst werden.
Wir wollen zunächst versuchen, die Methode mit Hilfe
einer while-Schleife zu realisieren. Der Gedanke ist
naheliegend. Solange es sich noch nicht um die leere Liste handelt,
wird ein Zähler, der die Elemente zählt, hochgezählt:
int lengthWhile(){
int erg=0;
Li xs = this;
while (!xs.isEmpty()){
erg= erg +1;
xs = xs.tail();
}
return erg;
}
Schaut man sich diese Lösung genauer an, so sieht man, daß sie die
klassischen drei Bestandteile einer for-Schleife enthält:
die Initialisierung einer
Laufvariablen: List xs = this;eine bool'sche Bedingung zur
Schleifensteuerung: !xs.isEmpty()eine Weiterschaltung der
Schleifenvariablen: xs = xs.tail();
Damit ist die Schleife, die über die Elemente einer Liste iteriert, ein
guter Kandidat für eine for-Schleife. Wir können das Programm
entsprechend umschreiben:
int lengthFor(){
int erg=0;
for (Li xs=this;!xs.isEmpty();xs=xs.tail()){
erg = erg +1;
}
return erg;
}
Eine Schleifenvariable ist also nicht unbedingt eine Zahl, die hoch
oder herunter gezählt wird, sondern kann auch ein Objekt sein, von
dessen Eigenschaften abhängt, ob die Schleife ein weiteres Mal zu
durchlaufen ist.
In solchen Fällen ist die Variante mit der for-Schleife der
Variante mit der while-Schleife vorzuziehen, weil somit die
Befehle, die die Schleife steuern, gebündelt zu Beginn der Schleife
stehen.
Eine sinnvolle Methode toString für Listen erzeugt einen
String, in dem die Listenelemente durch Kommas getrennt sind.
public String toString(){
return "("+toStringAux()+")";
}
private String toStringAux(){
if (isEmpty()) return "";
else if (tail().isEmpty()) return head().toString();
else return head().toString()+","+tail().toStringAux();
}Nehmen Sie beide der in diesem Kapitel entwickelten
Umsetzungen von Listen und fügen Sie ihrer Listenklassen folgende Methoden
hinzu. Führen Sie Tests für diese Methoden durch.
Object last(): gibt das letzte Element der Liste aus.List concat(List other) bzw.: Li concat(Li other): erzeugt eine neue Liste, die
erst die Elemente der this-Liste und dann
der other-Liste hat, es sollen also zwei Listen aneinander
gehängt werden. Object elementAt(int i): gibt das Element an einer
bestimmten Indexstelle der Liste zurück. Spezifikation:
elementAt(Cons(x,xs),1)x
elementAt(Cons(x,xs),n+1)elementAt(xs,n)
Unsere Umsetzung der Listen hat eine unschöne Schwäche. Die Elemente
einer Liste sind lediglich vom Typ Object. Das hat zwar den
Vorteil, daß wir beliebige Objekte in einer Liste speichern können,
aber, wenn wir z.B.mit der Methode elementAt auf ein Element
der Liste zugreifen, wissen wir über den Typ dieses Elements nicht konkretes mehr.
Dieses liegt an einer Schäche des Typssystems von Java. Um Listen so
zu gestallten, daß Objekte beliebigen Typs gespeichert werden konnten,
mußten wir für den ersten Parameter des Konstruktors Cons den
Typ Object wählen. Daraus ergibt sich aber auch der
Rückgabetyp Object für den Selektor head, der nach
unserer Spezifkation die einzige Möglichkeit ist, wieder auf
Listenelemente zuzugreifen. Java fehlt derzeit eine Möglichkeit, in
einer Klassendeklaration von einen beliebigen aber festen Typen zu
sprechen.Dieses wird sich mit den generischen Typen
in einer der kommenden Javaversionen ändern. Benutzen wir
die oben definierte Listenklasse Li, so muß nach einer
Selektion eine Typzusicherung gemacht werden:
class ListTypeCast
public static void main(String [] args){
Li xs = new Li("friends",new Li ("romans",new Li()));
System.out.println(((String)xs.tail().head()).toUpperCase());
}
}
Eine Typzusicherung vorzunehmen, ist immer eine unschöne Lösung. Die
Typzusicherung kann bei falscher Programmierung zu Laufzeitfehlern
führen. Java ist nicht in der Lage bereits zur Übersetzungszeit solche
Fehler abzufangen. Wenn wir eine derartige Typzusicherung nach der
Selektion eines Elements aus einer Liste verhindern wollen, so können
wir eine spezialisierte Liste schreiben, die nur Elemente eines
bestimmten Typs speichern kann. Hierzu schreiben wir eine Unterklasse
der Klasse Li, deren Konstruktor und Selektor nicht den
Typ Object als Parameter bzw.Ergebnistyp hat, sondern für
unser Beispiel den Typ String.
class StringLi extends Li{
StringLi(String x,StringLi xs){
super(x,xs);
}
StringLi(){
super();
}
String stringHead(){return (String)head();}
StringLi stringTail(){return (StringLi)tail();}
}
Java erlaubt uns nicht die Methoden head und tail aus
der Oberklasse mit anderen, spezialisierten Rückgabetypen zu überschreiben. Daher sind
die neuen Methoden stringHead und stringTail definiert
worden. Dieser neue Unterklasse stellt nur noch Listen mit String-Elementen
dar. Werden nun Listenelemente selektiert, so ist ihr
Typ String, die Typzusicherung entfällt.
class TestLi {
public static void main(String [] args){
StringLi xs
= new StringLi("friends"
,new StringLi("romans"
,new StringLi()));
System.out.println(xs.stringTail().stringHead().toUpperCase());
System.out.println(xs.length());
}
}
Wie man an den Aufruf der Methode length sieht, lassen sich
die geerbten Methoden auf Objekten des Typs StringLi weiterhin
benutzen.
Die Definition einer für einen bestimmten Elementtyp spezialisierten
Liste lohnt sich besonders, wenn in einem Projekt besonders viele
Listen mit diesem spezialisierten Typ vorkommen.
Eine sehr häufig benötigte Eigenschaft von Listen ist, sie nach einer
bestimmten Größenrelation sortieren zu können. Wir wollen in diesem
Kapitel Objekte des Typs String sortieren. Über die
Methode compareTo der Klasse String läßt sich eine
kleiner-gleich-Relation auf Zeichenketten definieren:
Diese statische Methode werden wir zum Sortieren benutzen.
In den folgenden Abschnitte werden wir drei verschiedene Verfahren der
Sortierung kennenlernen.
Die einfachste Methode einer Sortierung ist, neue Elemente in einer
Liste immer so einzufügen, daß nach dem Einfügen eine sortierte Liste
entsteht. Wir definieren also zunächst eine Klasse, die es erlaubt,
Elemente so einzufügen, daß alle vorhergehenden Elemente kleiner und
alle nachfolgenden Elemente größer sind. Diese Klasse braucht unsere
entsprechenden Konstruktoren und einen neuen Selektor, der den
spezialisierteren Rückgabetyp hat:
Die entscheidende neue Methode für diese Klasse ist die
Einfügemethode insertSorted. Sie erzeugt eine neue Liste, in
die das neue Element eingefügt wird:
Im Falle einer leeren Liste wird die einelementige Liste zurückgegeben:
Andernfalls wird unterschieden, ob das einzufügende Element kleiner als
das erste Listenelement ist. Ist das der Fall, so wird das neue
Element in die Schwanzliste sortiert eingefügt:
else if (StringOrdering.lessEqual((String)head(),x)){
return new SortStringLi
((String)head()
,((SortStringLi)tail()).insertSorted(x));
Anderfalls wird das neue Element mit dem Konstruktor vorne eingefügt:
} else return new SortStringLi(x,this);
}//method insertSorted
Die eigentliche Sortiermethode erzeugt eine leere Ergebnisliste, in
die nacheinander die Listenelemente sortiert eingefügt werden:
SortStringLi getSorted(){
SortStringLi result = new SortStringLi();
for (Li xs= this;!xs.isEmpty();xs = xs.tail()){
result = result.insertSorted((String)xs.head());
}
return result;
}//method sort
Somit hat die Klasse SortStringLi eine Sortiermethode, die
wir in einer Hauptmethode testen können:
public static void main(String [] args){
SortStringLi xs
= new SortStringLi("zz"
,new SortStringLi("ab"
,new SortStringLi("aaa"
,new SortStringLi("aaa"
,new SortStringLi("aaz"
,new SortStringLi("aya"
,new SortStringLi()))))));
System.out.println("Die unsortierte Liste:");
System.out.println(xs);
Li ys = xs.getSorted();
System.out.println("Die sortierte Liste:");
System.out.println(ys);
}
}//class SortStringLi
Die Ausgabe unseres Testprogramms zeigt, daß tatsächlich die Liste
sortiert wird:
java SortStringLi
Die unsortierte Liste:
(zz,ab,aaa,aaa,aaz,aya)
Die sortierte Liste:
(aaa,aaa,aaz,ab,aya,zz)
sep@swe10:~/fh/prog1/Listen>]]>
Der Namen quick sort hat sich für eine Sortiermethode
durchgesetzt, die sich das Prinzip des Teilens des Problems zu eigen
macht, bis die durch Teilen erhaltenen Subprobleme trivial zu lösen
sind.Der Name quick sort ist insofern nicht immer
berechtigt, weil in
bestimmten Fällen das Verfahren nicht sehr
schnell im Vergleich zu anderen Verfahren ist.
Mathematisch
läßt sich das Verfahren wie durch folgende Gleichungen beschreiben:
quicksort(Empty()) Empty()
quicksort(Cons(x,xs))quicksort(y|y\in xs, y<=x) ++ Cons(x,quicksort(y|y\in xs, y>x))
Die erste Gleichung spezifiziert, daß das Ergebnis der Sortierung
einer leeren Liste eine leere Liste zum Ergebnis hat.
Die zweite Gleichung spezifiziert den Algorithmus für nichtleere
Listen. Der in der Gleichung benutzte Operator ++ steht für
die Konkatenation zweier Listen mit der in der letzten Aufgabe
geschriebenen Methode concat.
Die Gleichung ist zu lesen als:
Um eine nichtleere Liste zu sortieren, filtere alle Elemente aus der
Schwanzliste, die kleiner sind als der Kopf der Liste. Sortiere diese
Teilliste. Mache dasselbe mit der Teilliste aus den Elementen des
Schwanzes, die größer als das Kopfelement sind. Hänge schließlich
diese beiden sortierten Teillisten aneinander und das Kopfelement
dazwischen.
Anders als in unserer obigen Sortierung durch Einfügen in eine neue Liste, für
die wir eine Unterklasse der Klasse Li geschrieben haben,
wollen wir die Methode quicksort in der
Klasse Li direkt implementieren,
d.h.allgemein für alle Listen zur
Verfügung stellen.
Aus der Spezifikation geht hervor, daß wir als zentrales Hilfsmittel
eine Methode brauchen, die nach einer bestimmten Bedingung Elemente
aus einer Liste filtert. Wenn wir diesen Mechanismus haben, so ist der
Rest des Algorithmus mit Hilfe der Methode concat trivial
direkt aus der Spezifikation ableitbar. Um die
Methode filter möglichst allgemein zu halten, können wir sie
so schreiben, daß sie ein Objekt bekommt, in dem eine Methode die
Bedingung, nach der zu filtern ist, angibt. Eine solche Klasse sieht
allgemein wie folgt aus:
class FilterCondition {
boolean condition(Object testMe){
return true;
}
}Li append(Li ys){
if (isEmpty())return ys;
return new Li(head(),tail().append(ys));
}
Für bestimmte Bedingungen können für eine solche Klasse Unterklassen
definiert werden, die die Methode condition entsprechend
überschreiben. Für unsere Sortierung brauchen wir zwei
Bedingungen: einmal wird ein Objekt getestet, ob es größer ist als
ein vorgegebenes Objekt, ein anderes Mal, ob es kleiner ist. Wir
erhalten also folgende kleine Klassenhierarchie aus
Abbildung:
Die beiden Unterklassen brauchen jeweils ein Feld, in dem das Objekt
gespeichert ist, mit dem das Element im Größenvergleich getestet wird.
Die für den Sortieralgorithmus benötigte Methode filter kann
entsprechend ein solches Objekt also Argument bekommen:
Li filter(Condition cond);
Die entscheidende Methode für den Sortieralgorithmus ist filter. Mit
dieser Methode werden entsprechend einer Filterbedingung bestimmte Elemente
aus einer Liste selektiert.
In der Klasse Li kann nun die
Methode filter eingefügt werden:
Li filter(FilterCondition cond){
Li result = new Li();
//test all elements of this list
for (Li xs=this;!xs.isEmpty();xs=xs.tail()){
//in case that the condition is true for the element
if (cond.condition(xs.head())) {
//then add it to the result
result = new Li(xs.head(),result);
}
}
return result;
}
Hiermit ist die Hauptarbeit für den quick sort-Algorithmus
getan.
Bevor wir die Methode quicksort implementieren, wollen wir ein
paar Tests für unsere Methode filter schreiben. Hierzu schreiben wir
Klassen für die Bedingungen, nach denen wir Elemente aus einer Liste filtern
wollen:
Zunächst eine Bedingung, die Stringobjekte mit einer Länge größer als 10
selektiert:
class LongString extends FilterCondition{
boolean condition(Object testMe){
return ((String)testMe).length()>10;
}
}
Eine weitere Bedingung soll testen, ob ein Stringobjekt mit einem
Großbuchstaben 'A' beginnt:
class StringStartsWithA extends FilterCondition{
boolean condition(Object testMe){
return ((String)testMe).charAt(0)=='A';
}
}
Und eine dritte Bedingung, die wahr wird für Stringobjekte, die kein großes
'A' enthalten:
class ContainsNoA extends FilterCondition{
boolean condition(Object testMe){
for (int i= 0;i<((String)testMe).length();i=i+1){
final char c = ((String)testMe).charAt(i);
if (c=='A' || c=='a') return false;
}
return true;
}
}
Probeweise filtern wir jetzt einmal eine Liste nach diesen drei Bedingungen:
class TestFilter {
static
Li XS = new Li("Shakespeare",
new Li("Brecht",
new Li("Achternbusch",
new Li("Calderon",
new Li("Moliere",
new Li("Sorokin",
new Li("Schimmelpfennig",
new Li("Kane",
new Li("Wilde",
new Li())))))))));
public static void main(String [] _){
System.out.println(XS);
System.out.println(XS.filter(new ContainsNoA()));
System.out.println(XS.filter(new StringStartsWithA()));
System.out.println(XS.filter(new LongString()));
}
}
In der Ausgabe können wir uns vom korrekten Lauf der
Methode filter überzeugen:
java TestFilter
(Shakespeare,Brecht,Achternbusch,Calderon,Moliere,Sorokin,Schimmelpfennig,Kane,Wilde)
(Wilde,Schimmelpfennig,Sorokin,Moliere,Brecht)
(Achternbusch)
(Schimmelpfennig,Achternbusch,Shakespeare)
sep@linux:~/fh/prog1/examples/classes>]]>
Interessant zu beobachten mag sein, daß unsere Methode filter die
Reihenfolge der Elemente der Liste umdreht.
Zurück zu unserer eigentlichen Aufgabe, dem quick sort-Verfahren.
Hier wollen wir die Eingabeliste einmal nach allen
Elementen, die kleiner als das erste Element sind, filtern und einmal nach
allen Elementen, die größer als dieses sind. Hierzu brauchen wir zwei
Filterbedingungen. Diese hängen beide von einem bestimmten Element, nämlich
dem ersten Element der Liste, ab.
Die beiden Klassen für die Bedingung lassen sich relativ einfach aus
der Modellierung ableiten:
0;
}
}]]>
Entsprechend für die größer-Relation:
Die Methode quicksort läßt sich direkt aus der
formalen Spezifikation ableiten:
Li quicksort(){
Li result = new Li();
if (!isEmpty()){
result
= //filter the smaller elements out of the tail
tail().filter(new LessEqualX((String)head()))
//sort these
.quicksort()
//concatenate it with the sorted
//sublist of greater elements
.append(new Li(head()
,tail().filter(new GreaterX((String)head()))
.quicksort()
));
}
return result;
}
Obige Umsetzung des quick sort-Algorithmus ist allgemeiner
als der zuvor entwickelte Algorithmus zur Sortierung durch
Einfügen. Die entscheidende Methode filter ist parameterisiert
über die Bedingung, nach der gefiltert werden soll. Damit läßt sich
schnell eine quick sort-Methode schreiben, deren Filter nicht
auf der größer-Relation von String Objekten basiert. Hierzu
sind nur entsprechende Unterklassen der
Klasse FilterCondition zu schreiben und in der Sortiermethode
zu benutzen. Wieder einmal haben wir unsere strenge Trennung aus der
anfänglichen Arbeitshypothese durchbrochen: Die Objekte der
Klasse FilterCondition stellen nicht primär Daten dar,
sondern eine Methode, die wir als Argument einer anderen
Methode (der Methode filter) übergeben.
Es ist naheliegend, die Parameterisierung über die eigentliche
Ordnungsrelation der Sortiermethode mitzugeben, also eine
Sortiermethode zu schreiben, die einen Parameter hat, der angibt, nach
welchem Kriterium zu sortieren ist:
Li sortBy(Relation rel)
Hierzu brauchen wir eine Klasse Relation, die eine Methode
hat, in der entschieden wird, ob zwei Objekte in einer Relation stehen:
class Relation {
boolean lessEqual(Object x,Object y){
return true;
}
}
Je nachdem, was wir sortieren wollen, können wir eine Subklasse der
Klasse Relation definieren, die uns sagt, wann zwei Objekte
in der kleiner-Relation stehen. Für Objekte des
Typs String bietet folgende Klasse eine adäquate Umsetzung:
class StringLessEqual extends Relation {
boolean lessEqual(Object x,Object y){
return ((String)x).compareTo((String)y)<=0;
}
}
Um den quick sort-Algorithmus anzuwenden, benötigen wir nun
noch eine Möglichkeit, aus einer Relation die beiden Bedingungen für
die Methode filter generieren. Wir schreiben zwei neue
Subklassen der Klasse FilterCondition, die für eine Relation
jeweils kleinere bzw.größere Objekte als ein vorgegebenes Objekt
filtern.
class OrderingCondition extends FilterCondition{
Object x;
Relation rel;
OrderingCondition(Object x,Relation rel){
this.x=x;
this.rel = rel;
}
boolean condition(Object y){
return rel.lessEqual(y,x);
}
}
Entsprechend für die negierte Relation:
class NegativeOrderingCondition extends FilterCondition{
Object x;
Relation rel;
NegativeOrderingCondition(Object x,Relation rel){
this.x=x;
this.rel = rel;
}
boolean condition(Object y){
return !rel.lessEqual(y,x);
}
}
Damit haben wir alle Bausteine zur Hand, mit denen ein über die
Ordungsrelation parameterisierte Sortiermethode geschrieben werden
kann:
Beim Aufruf der Methode sortBy ist ein Objekt mitzugeben,
das die Relation angibt, nach der sortiert werden soll.
Im folgenden
Beispiel werden Strings einmal nach ihrer lexikographischen Ordnung,
einmal nach ihrer Länge sortiert:
class StringLengthLessEqual extends Relation {
boolean lessEqual(Object x,Object y){
return ((String)x).length()<= ((String)y).length();
}
}
In der Testmethode können wir die Methode sortBy jeweils mit einer
der Bedingungen aufrufen:
class TestSortBy{
public static void main(String [] args){
Li xs = TestFilter.XS;
System.out.println("Die unsortierte Liste:");
System.out.println(xs);
System.out.println("Die alphabetisch sortierte Liste:");
System.out.println(xs.sortBy(new StringLessEqual()));
System.out.println("Die nach der Länge sortierte Liste:");
System.out.println(xs.sortBy(new StringLengthLessEqual()));
}
}
Und tatsächlich können wir jetzt die Sortiermethode benutzen, um nach
unterschiedlichen Kriterien zu sortieren:
java TestSortBy
Die unsortierte Liste:
(Shakespeare,Brecht,Achternbusch,Calderon,Moliere,Sorokin,Schimmelpfennig,Kane,Wilde)
Die alphabetisch sortierte Liste:
(Achternbusch,Brecht,Calderon,Kane,Moliere,Schimmelpfennig,Shakespeare,Sorokin,Wilde)
Die nach der Länge sortierte Liste:
(Kane,Wilde,Brecht,Moliere,Sorokin,Calderon,Shakespeare,Achternbusch,Schimmelpfennig)
sep@linux:~/fh/prog1/examples/classes>]]>
Verfolgen Sie schrittweise mit Papier und Beistift, wie
der quicksort Algorithmus die folgenden zwei Listen sortiert:
("a","b","c","d","e")("c","a","b","d","e")
Diese Aufgabe soll mir helfen, Listen für Ihre Leistungsbewertung zu
erzeugen.
Implementieren Sie für Ihre Listenklasse eine
Methode String toHtmlTable(), die für Listen Html-Code für
eine Tabelle erzeugt, z.B:
erstes Listenelement
zweites Listenelement
drittes Listenelement
]]>Nehmen Sie die Klasse Student, die
Felder für Namen, Vornamen und Matrikelnummer hat. Implementieren
Sie für diese Klasse eine Methode String toTableRow(), die
für Studenten eine Zeile einer Html-Tabelle erzeugt:
Student s1 = new Student("Müller","Hans",167857);
System.out.println(s1.toTableRow());
soll folgende Ausgabe ergeben:
Müller
Hans
167857
]]>
Ändern Sie die Methode toString so, daß sie dasselbe Ergebnis wie die
neue Methode toTableRow hat.
Legen Sie eine Liste von Studenten an, sortieren Sie diese
mit Hilfe der Methode sortBynach
Nachnamen und Vornamen und erzeugen Sie eine Html-Seite, die die
sortierte Liste anzeigt.
Sie können zum Testen die folgende Klasse benutzen: hallo");
}
JFrame frame;
JTextPane ausgabe = new JTextPane();
public HtmlView() {
ausgabe.setEditorKit(new HTMLEditorKit());
add(ausgabe);
}
void setText(String htmlString){
ausgabe.setText(htmlString);
frame.pack();
ausgabe.repaint();
}
void run(){
frame = new JFrame("HtmlView");
frame.getContentPane().add(this);
frame.pack();
frame.setVisible(true);
}
}]]>
Der sogenannte bubble sort-Algorithmus ist nicht unbedingt
geeignet für Listen mit dynamischer Länge, sondern für Reihungen mit
fester Länge (arrays), die wir in einem späteren Kapitel
kennenlernen werden. Daher werden wir diesen Algorithmus erst an späterer
Stelle betrachten.
Wir stellen den Algorithmus trotzdem bereits in
diesem Kapitel für Listen vor und werden ihn später auch noch einmal
für Reihungen implementieren.
Der Name bubble sort leitet sich davon
ab, daß Elemente wie die Luftblasen in einem Mineralwasserglass
innerhalb der Liste aufsteigen, wenn sie laut der Ordnung an ein
späteres Ende gehören. Ein vielleicht phonetisch auch ähnlicher
klingender deutscher Name wäre Blubbersortierung. Dieser Name
ist jedoch nicht in der deutschen Terminologie etabliert und es wird
in der Regel der englische Name genommen.
Die Idee des bubble sort ist, jeweils nur zwei benachbarte
Elemente einer Liste zu betrachten und diese gegebenenfalls in ihrer
Reihenfolge zu vertauschen. Eine Liste wird also von vorne bis hinten
durchlaufen, immer zwei benachbarte Elemente betrachtet und diese,
falls das vordere nicht kleiner ist als das hintere, getauscht. Wenn
die Liste in dieser Weise einmal durchgegangen wurde, ist sie entweder
fertig sortiert, oder muß in gleicher Weise nocheinmal durchgegangen
werden, solange bis keine Vertauschungen mehr vorzunehmen sind.
Die Liste ("z","b","c","a") wird durch
den bubble sort Algorithmus in folgender Weise sortiert:
1. Bubble-Durchlauf ("z","b","c","a") ("b","z","c","a") ("b","c","z","a") ("b","c","a","z")
Das Element "z" ist in diesem Durchlauf an das Ende der Liste
geblubbert.
2. Bubble-Durchlauf ("b","c","a","z") ("b","a","c","z")
In Diesem Durchlauf ist das Element "c" um einen Platz nach
hinten geblubbert.
3. Bubble-Durchlauf ("b","a","c","z") ("a","b","c","z")
Im letzten Schritt ist das Element "b" auf seine entgültige
Stelle geblubbert.
Die fundamentale Methode der Blubbersortierung ist das eigentliche
Blubbern, in dem die Liste einmal durchlaufen wird, und das Ergebnis
eine Liste ist, in der unter Umständen Elemente getauscht wurden. Das
Ergebnis eines Blubberdurchgangs ist entsprechend nicht nur die
geblubberte Liste, sondern auch bool'scher Wert, der angibt, ob
Elemente bei dem Blubberdurchgang vertauscht wurden. Das Ergebnis ist
also ein Paar von zwei Objekten. Um mit Methoden zwei
Ergebnisswerte zurückgeben zu können, definieren wir uns zunächst die
Klasse Pair, die zwei Objekte beliebigen Typs speichern kann.
class Pair {
Object fst;
Object snd;
Pair(Object fst,Object snd){
this.fst=fst;
this.snd=snd;
}
}
Mit den Feldern fst und snd kann jeweils auf eines
der beiden Objekte zugegriffen werden.
Die Methode bubble kann jetzt so definiert werden, daß sie
ein Objekt der Klasse Pair als Ergebnis hat. Im
Feld fst dieses Objektes wird ein Objekt des
Typs Boolean stehen, der angibt, ob Elemente vertauscht
wurden. Im Feld snd steht die eigentliche Liste. Wir
implementieren die Blubbersortierung für die Klasse Li.
Pair/*Boolean,Li*/ bubble (Relation rel){
Zunächst legen wir lokale Felder an für die zu bearbeitende Liste, für
das Ergebnis und ein bool'sches Feld, welches angibt, ob wir zwei
Elemente vertauscht haben.
Li bubbleMe = this;
Li bubbleResult = new Li();
boolean bubbleMade = false;
Im einfachsten Fall ist die Liste leer:
if (bubbleMe.isEmpty()) {bubbleMade= false;
}auch für einelementige Liste ist die Lösung
einfach: else if (bubbleMe.tail().isEmpty()) {
bubbleResult = bubbleMe;bubbleMade= false;
}
Im Falle von Listen mit mindestens zwei Elementen, können die ersten
beiden Elemente verglichen werden. Entweder sind diese bereits in
der richtigen Reihenfolge, dann kann auf der Restliste rekursiv
weitergearbeitet werden:
else if(rel.lessEqual(bubbleMe.head()
,bubbleMe.tail().head()))
{
Pair furtherBubbleRes = bubbleMe.tail().bubble(rel);
bubbleResult
= new Li(bubbleMe.head(),(Li)furtherBubbleRes.snd);
bubbleMade
= ((Boolean)furtherBubbleRes.fst).booleanValue();
}
Sind sie nicht in der richtigen Reihenfolge, so werden der Kopf und
der Kopf des Schwanzes getauscht und die Methode für diesen
neuen Schwannz rekursiv aufgerufen:
else {
bubbleMade = true;
Li furtherBubble
= (Li)new Li(bubbleMe.head(),bubbleMe.tail().tail())
.bubble(rel).snd;
bubbleResult
=new Li(bubbleMe.tail().head(),furtherBubble);
}
Schließlich wird das Ergebnisobjekt erzeugt und zurückgegeben.
return new Pair(new Boolean(bubbleMade),bubbleResult);
}
Die Methode bubble leistet den Hauptteil des
Sortieralgorithmus. Sie braucht nun nur noch so oft in einer Schleife
auf eine Liste aufgerufen werden, bis die Liste sortiert vorliegt:
Li bubbleSortBy(Relation rel){
Li bubbleMe = this;
boolean doBubble = true;
while (doBubble){
Pair furtherBubbleRes = bubbleMe.bubble(rel);
doBubble = ((Boolean)furtherBubbleRes.fst).booleanValue();
bubbleMe = (Li)furtherBubbleRes.snd;
}
return bubbleMe;
}
Im ersten Kapitel haben wir unter den Disziplinen der Programmierung auch die
formale Verifikation aufgezählt. Die formale Verifikation erlaubt es,
Eigenschaften von Programmen allgemein mathematisch zu beweisen. Anders als
durch Testfälle, die nur Aussagen über ein Programm für ausgewählte Fälle machen
können, können über die Verifikation allgemeine Aussagen bewiesen werden, die
sich auf alle möglichen Argumente für ein Programm beziehen können. Formale
Verifikation ist seit Jahrzehnten ein weites Feld der Forschung, die zumeist
im Gebiet der KI (künstlichen Intelligenz) angesiedelt ist.
Voraussetzung für eine formale Verifikation ist, daß sowohl die Datentypen als
auch die programmierten Algorithmen in einer formalen Weise spezifiziert und
notiert wurden. Für unsere Listentypen haben wir das bisher getan. Unsere
Listen sind daher bestens geeignet, um formale Beweise zu führen.
Das aus der Mathematik bekannte Verfahren der vollständigen Induktion über die
natürlichen Zahlen ist ein gängiges Verfahren zur Verifikation von
Algorithmen. Ein Induktionsbeweis geht dabei in zwei Schritten. Zunächst wird
im sogenannten Induktionsanfang die Aussage für das kleinste Datum geführt,
bei natürlichen Zahlen also für die Zahl 0. Im zweiten Schritt, dem
sogenannten Induktionsschritt, wird angenommen, daß die Aussage bereits für
alle Werte kleiner eines bestimmten Wertes n bewiesen wurde. Dann wird
versucht, unter dieser Annahme die Aussage auch für n zu beweisen. Sind
beide Beweisschritte gelungen, so ist die Aussage für alle endlichen Werte
bewiesen.
Das Beweisverfahren der Induktion läßt sich auf rekursiv definierte
Datentypen, so wie unsere Listen, anwenden. Der Basisfall, also entsprechend
der 0 bei den natürlichen Zahlen, sind die Daten, die durch einen nicht
rekursiven Konstruktor erzeugt werden. Für Listen entsprechend sind dieses die
Listen, die mit dem Konstruktor für leere Listen erzeugt wurden. Im
Induktionsschritt wird angenommen, die zu beweisende Aussage sei bereits für
Daten, die mit weniger Konstruktoren erzeugt wurden, bewiesen. Unter dieser
Annahme wird versucht zu zeigen, daß die Aussage auch für mit einem weiteren
Konstruktor erzeugte Daten gilt. Für Listen bedeutet das, man versucht, die
Annahme für die Liste der Form Cons(x,xs) zu beweisen unter der
Annahme, daß die Aussage für die Liste xs bereits bewiesen wurde.
Als Beispiel wollen wir eine Eigenschaft über Listen im Zusammenhang mit den
Funktionen concat und length beweisen. Wir wollen beweisen,
daß die Länge der Konkatenation zweier Listen gleich der Summe der Längen der
beiden Listen ist, also daß für alle Listen xs und ys gilt:
length(concat(xs,ys)) length(xs) + length(ys)
Dabei seien die beiden Funktionen wieder spezifiziert als:
length(Empty())0
length(Cons(x,xs))1+length(xs)
und
concat(Empty(),ys)ys
concat(Cons(x,xs),ys)Cons(x,concat(xs,ys))
Wir werden die Aussage beweisen mit einer Induktion über das erste Argument
der Funktion concat, also dem xs:
Induktionsanfang:
Wir versuchen, die Aussage zu beweisen mit xs=Empty(). Wir erhalten
die folgende Aussage:
length(concat(Empty(),ys)) length(Empty()) + length(ys)
Wir können jetzt auf beiden Seiten der Gleichung durch Reduktion mit den
Gleichungen aus der Spezifikation die Gleichung vereinfachen:
length(concat(Empty(),ys)) length(Empty()) + length(ys)
length(ys) length(Empty()) + length(ys)
length(ys) 0 + length(ys)
length(ys) length(ys)
Wir haben die Aussage auf eine Tautologie reduziert. Für xs als leere
Liste ist damit unsere Aussage bereits bewiesen.
Induktionsschritt: Jetzt wollen wir die Aussage
für xs=Cons(x',xs') beweisen, wobei wir als
Induktionsvoraussetzung annehmen, daß sie für xs' bereits wahr ist, daß also
gilt:
length(concat(xs',ys)) length(xs') + length(ys)
Hierzu stellen wir die zu beweisende Gleichug auf und reduzieren sie auf
beiden Seiten:
length(concat(Cons(x',xs'),ys)) length(Cons(x',xs')) + length(ys)
length(Cons(x',concat(xs',ys))) length(Cons(x',xs')) + length(ys)
1+length(concat(xs',ys)) 1+length(xs') + length(ys)
length(concat(xs',ys)) length(xs') + length(ys)
Die letzte Gleichung ist gerade die Induktionsvoraussetzung, von der wir
angenommen haben, daß diese bereits wahr ist. Wir haben unsere Aussage für
alle endlichen Listen bewiesen.
u
Java bietet die Möglichkeit, Klassen in Paketen zu sammeln. Die Klassen
eines Paketes bilden zumeist eine funktional logische Einheit. Pakete
sind hierarchisch strukturiert, d.h.Pakete können Unterpakete haben.
Damit entsprechen Pakete Ordnern im Dateisystem. Pakete ermöglichen
verschiedene Klassen gleichen Namens, die
unterschiedlichen Paketen zugeordnet sind.
Zu Beginn einer Klassendefinition kann eine Paketzugehörigkeit für die
Klasse definiert werden. Dieses geschieht mit dem
Schlüsselwort package gefolgt von dem gewünschten Paket. Die
Paketdeklaration schließt mit einem Semikolon.
Folgende Klasse definiert sie dem Paket testPackage zugehörig:
package testPackage;
class MyClass {
}
Unterpakete werden von Paketen mit Punkten abgetrennt. Folgende Klasse
wird dem Paket panitz zugeordnet, das ein Unterpaket des
Pakets tfhberlin ist, welches wiederum ein Unterpaket des
Pakets de ist:
package de.tfhberlin.panitz.testPackages;
class TestPaket {
public static void main(String [] args){
System.out.println("hello from package \'testpackages\'");
}
}
Paketnamen werden per Konvention in lateinischer Schrift immer mit
Kleinbuchstaben als erstem Buchstaben geschrieben.
Wie man sieht, kann man eine weltweite Eindeutigkeit seiner Paketnamen
erreichen, wenn man die eigene Webadresse hierzu
benutzt.Leider ist es in Deutschland weit verbreitet, einen
Bindestrich in Webadressen zu verwenden. Der Bindestrich ist leider
eines der wenigen Zeichen, die Java in Klassen- und Paketnamen nicht
zuläßt. Dabei wird die Webadresse rückwärts verwendet.
Paketname und Klassenname zusammen identifizieren eine Klasse
eindeutig. Jeder Programmierer schreibt sicherlich eine Vielzahl von
Klassen Test, es gibt aber in der Regel nur einen
Programmierer, der diese für das
Paket de.tfhberlin.panitz.testPackages schreibt. Paket- und
Klassenname zusammen durch einen Punkt getrennt werden
der vollqualifizierte Name der Klasse genannt, im obigen
Beispiel ist entsprechend der vollqualifizierte Name: de.tfhberlin.panitz.testPackages.Test
Der Name einer Klasse ohne die Paketnennung heißt unqualifiziert.
Bei größeren Projekten ist es zu empfehlen, die Quelltexte der Javaklassen in
Dateien zu speichern, die im Dateisystem in einer Ordnerstruktur, die der
Paketstruktur entspricht, liegen. Dieses ist allerdings nicht unbedingt
zwingend notwendig. Hingegen zwingend notwendig ist es, die erzeugten
Klassendateien in Ordnern entsprechend der Paketstruktur zu speichern.
Der Javainterpreter java sucht nach Klassen in den Ordnern
entsprechend ihrer Paketstruktur. java erwartet also, daß die
obige Klasse Test in einem Ordner testPackages steht, der
ein Unterordner des Ordners panitz ist, der ein Unterordner des
Ordners tfhberlin ist.usw. java sucht
diese Ordnerstruktur von einem oder mehreren Startordnern ausgehend.
Die Startordner werden in einer Umgebungsvariablen CLASSPATH des
Betriebssystems und über den
Kommandozeilenparameter -classpath festgelegt.
Der Javaübersetzer javac hat eine Option, mit der gesteuert
wird, daß javac für seine .class-Dateien die
notwendige Ordnerstruktur erzeugt und die Klassen in die
ihren Paketen entsprechenden Ordner schreibt. Die Option
heißt -d. Dem -d ist nachgestellt, von welchem
Startordner aus die Paketordner erzeugt werden sollen. Memotechnisch steht
das -d für destination.
Wir können die obige Klasse z.B.übersetzen mit folgendem Befehl auf
der Kommandozeile: javac -d . Test.java
Damit wird ausgehend vom aktuellem VerzeichnisDer Punkt steht in den
meisten Betriebssystemen für den aktuellen Ordner, in dem gerade ein Befehl
ausgeführt wird. ein
Ordner de mit Unterordner tfhberlin etc.erzeugt.
Um Klassen vom Javainterpreter zu starten, reicht es nicht, ihren Namen
anzugeben, sondern der vollqualifizierte Name ist anzugeben. Unsere
obige kleine Testklasse wird also wie folgt gestartet:
java de.tfhberlin.panitz.testPackages.Test
hello from package 'testpackages'
sep@swe10:~/>]]>
Jetzt erkennt man auch, warum dem Javainterpreter nicht die
Dateiendung .class mit angegeben wird. Der Punkt separiert
Paket- und Klassennamen.
Aufmerksame Leser werden bemerkt haben, daß der Punkt in Java durchaus
konsistent mit einer Bedeutung verwendet wird: hierzu lese man ihn
als 'enthält ein'. Der Ausdruck: de.tfhberlin.panitz.testPackages.Test.main(args)
liest sich so als: das Paket de enthält ein
Unterpaket tfhberlin, das ein
Unterpaket panitz enthält, das ein
Unterpaket testpackages enthält, das eine
Klasse Test enthält, die eine Methode main enthält.
Die mit Java mitgelieferten Klassen sind auch in Paketen gruppiert. Die
Standardklassen wie z.B.String und System und
natürlich auch Object liegen im
Java-Standardpaket java.lang. Java hat aber noch eine ganze
Reihe weitere Pakete, so z.B.java.util, in dem sich
Listenklassen befinden, java.applet, in dem Klassen zur
Programmierung von Applets auf HTML-Seiten liegen,
oder java.io, welches Klassen für Eingaben und Ausgaben enthält.
Um Klassen benutzen zu können, die in anderen Paketen liegen, müssen
diese eindeutig über ihr Paket identifiziert werden. Dieses kann
dadurch geschehen, daß die Klassen immer vollqualifiziert angegeben
werden. Im folgenden Beispiel benutzen wir die
Standardklasse ArrayList aus dem Paket java.util.
package de.tfhberlin.panitz.utilTest;
class TestArrayList {
public static void main(String [] args){
java.util.ArrayList xs = new java.util.ArrayList();
xs.add("friends");
xs.add("romans");
xs.add("countrymen");
System.out.println(xs);
}
}
Wie man sieht, ist der Klassenname auch beim Aufruf des Konstruktors
vollqualifiziert anzugeben.
Vollqualifizierte Namen können sehr lang werden.
Wenn Klassen, die in einem anderen Paket als die eigene Klasse liegen,
unqualifiziert benutzt werden sollen, dann kann dieses zuvor angegeben
werden. Dieses
geschieht zu Beginn einer Klasse in einer Importanweisung. Nur die
Klassen aus dem Standardpaket java.lang brauchen nicht
explizit durch eine Importanweisung bekannt gemacht zu werden.
Unsere Testklasse aus dem letzten Abschnitt kann mit Hilfe einer
Importanweisung so geschrieben werden, daß die
Klasse ArrayList unqualifiziert benutzt werden kann:
package de.tfhberlin.panitz.utilTest;
import java.util.ArrayList;
class TestImport {
public static void main(String [] args){
ArrayList xs = new ArrayList();
xs.add("friends");
xs.add("romans");
xs.add("countrymen");
System.out.println(xs);
}
}
Es können mehrere Importanweisungen in einer Klasse stehen. So können
wir z.B. zusätzlich die Klasse Vector importieren:
package de.tfhberlin.panitz.utilTest;
import java.util.ArrayList;
import java.util.Vector;
class TestImport2 {
public static void main(String [] args){
ArrayList xs = new ArrayList();
xs.add("friends");
xs.add("romans");
xs.add("countrymen");
System.out.println(xs);
Vector ys = new Vector();
ys.add("friends");
ys.add("romans");
ys.add("countrymen");
System.out.println(ys);
}
}
Wenn in einem Programm viele Klassen eines Paketes benutzt werden, so
können mit einer Importanweisung auch alle Klassen dieses Paketes
importiert werden. Hierzu gibt man in der Importanweisung einfach
statt des Klassennamens ein * an.
package de.tfhberlin.panitz.utilTest;
import java.util.*;
class TestImport3 {
public static void main(String [] args){
List xs = new ArrayList();
xs.add("friends");
System.out.println(xs);
Vector ys = new Vector();
ys.add("romans");
System.out.println(ys);
}
}
Ebenso wie mehrere Klassen können auch mehrere komplette Pakete
importiert werden. Es können auch gemischt einzelne Klassen und
ganze Pakete importiert werden.
SichtbarkeitenMan findet in der Literatur auch den Ausdruck
Erreichbarkeiten. erlauben es, zu kontrollieren,
wer auf Klassen und ihre Eigenschaften zugreifen kann.
Das wer bezieht sich hierbei auf andere Klassen und Pakete.
Für Klassen gibt es zwei Möglichkeiten der Sichtbarkeit. Entweder darf
von überall aus eine Klasse benutzt werden oder nur von Klassen im
gleichen Paket. Syntaktisch wird dieses dadurch ausgedrückt, daß der
Klassendefinition entweder das Schlüsselwort public
vorangestellt ist oder aber kein solches Attribut voransteht:
package de.tfhberlin.panitz.p1;
public class MyPublicClass {
}package de.tfhberlin.panitz.p1;
class MyNonPublicClass {
}
In einem anderen Paket dürfen wir nur die als öffentlich deklarierte
Klasse benutzen. Folgende Klasse übersetzt fehlerfrei:
package de.tfhberlin.panitz.p2;
import de.tfhberlin.panitz.p1.*;
class UsePublic {
public static void main(String [] args){
System.out.println(new MyPublicClass());
}
}
Der Versuch, eine nicht öffentliche Klasse aus einem anderen Paket
heraus zu benutzen, gibt hingegen einen Übersetzungsfehler:
package de.tfhberlin.panitz.p2;
import de.tfhberlin.panitz.p1.*;
class UseNonPublic {
public static void main(String [] args){
System.out.println(new MyNonPublicClass());
}
}
Java gibt bei der Übersetzung eine entsprechende gut verständliche
Fehlermeldung:
javac -d . UseNonPublic.java
UseNonPublic.java:7: de.tfhberlin.panitz.p1.MyNonPublicClass is not
public in de.tfhberlin.pantitz.p1;
cannot be accessed from outside package
System.out.println(new MyNonPublicClass());
^
UseNonPublic.java:7: MyNonPublicClass() is not
public in de.tfhberlin.panitz.p1.MyNonPublicClass;
cannot be accessed from outside package
System.out.println(new MyNonPublicClass());
^
2 errors
sep@swe10:~>]]>
Damit stellt Java eine Technik zur Verfügung, die es erlaubt,
bestimmte Klassen eines Softwarepaketes als rein interne Klassen zu
schreiben, die von außerhalb des Pakets nicht benutzt werden können.
Java stellt in Punkto Sichtbarkeiten eine noch feinere Granularität
zur Verfügung. Es können nicht nur ganze Klassen als nicht-öffentlich
deklariert , sondern für einzelne Eigenschaften von Klassen
unterschiedliche Sichtbarkeiten deklariert werden.
Für Eigenschaften gibt es vier verschiedene Sichtbarkeiten: public, protected, kein Attribut, private
Sichbarkeiten hängen zum einem von den Paketen ab, in denen sich die
Klassen befinden, darüberhinaus unterscheiden sich Sichtbarkeiten auch
darin, ob Klassen Unterklassen voneinander sind. Folgende Tabelle gibt
eine Übersicht über die vier verschiedenen Sichtbarkeiten:
AttributSichtbarkeitpublicDie Eigenschaft darf von jeder Klasse aus
benutzt werden. protected
Die Eigenschaft darf für jede Unterklasse und jede Klasse im gleichen Paket
benutzt werden. kein AttributDie Eigenschaft darf nur von Klassen im
gleichen Paket benutzt werden. privateDie Eigenschaft darf nur von der Klasse, in
der sie definiert ist, benutzt werden.
Damit kann in einer Klasse auf Eigenschaften mit jeder dieser vier
Sichtbarkeiten zugegriffen werden. Wir können die Fälle einmal systematisch
durchprobieren. In einer öffentlichen Klasse eines
Pakets p1 definieren wir hierzu vier Felder mit den vier
unterschiedlichen Sichtbarkeiten:
package de.tfhberlin.panitz.p1;
public class VisibilityOfFeatures{
private String s1 = "private";
String s2 = "package";
protected String s3 = "protected";
public String s4 = "private";
public static void main(String [] args){
VisibilityOfFeatures v = new VisibilityOfFeatures();
System.out.println(v.s1);
System.out.println(v.s2);
System.out.println(v.s3);
System.out.println(v.s4);
}
}
In der Klasse selbst können wir auf alle vier Felder zugreifen.
In einer anderen Klasse, die im gleichen Paket ist, können private
Eigenschaften nicht mehr benutzt werden:
package de.tfhberlin.panitz.p1;
public class PrivateTest
{
public static void main(String [] args){
VisibilityOfFeatures v = new VisibilityOfFeatures();
//s1 is private and cannot be accessed;
//we are in a different class.
//System.out.println(v.s1);
System.out.println(v.s2);
System.out.println(v.s3);
System.out.println(v.s4);
}
}
Von einer Unterklasse können unabhängig von ihrem Paket
die geschützten Eigenschaften benutzt werden. Ist die
Unterklasse in einem anderen Paket, können Eigenschaften mit der
Sichtbarkeit package nict mehr benutzt werden:
package de.tfhberlin.panitz.p2;
import de.tfhberlin.panitz.p1.VisibilityOfFeatures;
public class PackageTest extends VisibilityOfFeatures{
public static void main(String [] args){
PackageTest v = new PackageTest();
//s1 is private and cannot be accessed
// System.out.println(v.s1);
//s2 is package visible and cannot be accessed;
//we are in a different package.
//System.out.println(v.s2);
System.out.println(v.s3);
System.out.println(v.s4);
}
}
Von einer Klasse, die weder im gleichen Paket noch eine
Unterklasse ist, können nur noch öffentliche Eigenschaften benutzt werden:
package de.tfhberlin.panitz.p2;
import de.tfhberlin.panitz.p1.VisibilityOfFeatures;
public class ProtectedTest {
public static void main(String [] args){
VisibilityOfFeatures v = new VisibilityOfFeatures();
//s1 is private and cannot be accessed
// System.out.println(v.s1);
//s2 is package visible and cannot be accessed. We are
//in a different package
//System.out.println(v.s2);
//s2 is protected and cannot be accessed.
//We are not a subclass
//System.out.println(v.s3);
System.out.println(v.s4);
}
}
Java wird in seinem Sichtbarkeitskonzept oft kritisiert, und das von
zwei Seiten. Einerseits ist es mit den vier Sichtbarkeiten schon
relativ unübersichtlich; die verschiedenen Konzepte der Vererbung und
der Pakete spielen bei Sichtbarkeiten eine Rolle. Andererseits ist es
nicht vollständig genug und kann verschiedene denkbare Sichtbarkeiten
nicht ausdrücken.
In der Praxis fällt die Entscheidung zwischen privaten und
öffentlichen Eigenschaften leicht. Geschützte Eigenschaften sind
hingegen selten. Das Gros der Eigenschaften hat die
Standardsichtbarkeit der Paketsichtbarkeit.
Der direkte Feldzufriff ist in bestimmten Anwendungen nicht immer
wünschenswert, so z.B. wenn ein Javaprogramm verteilt auf mehreren Rechner
ausgeführt wird oder wenn der Wert eines Feldes in einer Datenbank
abgespeichert liegt. Dann ist es sinnvoll, den Zugriff auf ein Feld durch zwei
Methoden zu kapseln: eine Methode, um den Wert des Feldes abzufragen, und eine
Methode, um das Feld mit einem neuen Wert zu belegen. Solche Methoden heißen
get- bzw.get-Methoden. Um technisch zu verhindern, daß direkt auf das Feld
zugegriffen wird, wird das Feld hierzu als private attributiert und
nur die get- und set-Methoden werden als öffentlich attributiert. Dabei ist
die gängige Namenskonvention, daß zu einem Feld mit Namen name die
get- und set-Methoden getName bzw.setName heißen.
Eine kleine Klasse mit Kapselung eines privaten Feldes:
package de.tfhberlin.sep.skript;
public class GetSetMethod {
private int value=0;
public void setValue(int newValue) {value=newValue;}
public int getvalue(){return value;}
}
Beim Überschreiben einer Methode darf ihr Sichtbarkeitsattribut nicht enger
gemacht werden. Man darf also eine öffentliche Methode aus der Oberklasse
nicht mit einer privaten, geschützten oder paketsichtbaren Methode
überschreiben. Der Javaübersetzer weist solche Versuche, eine Methode zu
überschreiben, zurück:
class OverrideToString {
String toString(){return "Objekt der Klasse OverrideToString";}
}
Der Versuch, diese Klasse zu übersetzen, führt zu folgender Fehlermeldung:
javac OverrideToString.java
OverrideToString.java:2:
toString() in OverrideToString cannot override toString() in java.lang.Object;
attempting to assign weaker access privileges;
was public
String toString(){return "Objekt der Klasse OverrideToString";}
^
1 error
sep@linux:~/fh/prog1/examples/src>]]>
Die Oberklasse Object enthält eine öffentliche
Methode toString. Wollen wir diese Methode überschreiben, muß sie
mindestens so sichtbar sein wie in der Oberklassen.
Mit den Sichtbarkeitsattributen können wir jetzt auch sicherstellen, daß für
unsere Listenimplementierung einmal erzeugte Listen unveränderbar
bleiben. Hierzu setzen wir die internen drei Felder der Listen einfach als
privat. Insgesamt erhalten wir dadurch die folgende Klasse für Listen:
Wir haben schon einige Situationen kennengelernt, in denen wir eine Klasse
geschrieben haben, von der nie ein Objekt konstruiert werden sollte,
sondern für die wir nur Unterklassen definiert und instanziiert
haben.
Die Methoden in
diesen Klassen hatten eine möglichst einfache Implementierung; sie
sollten ja nie benutzt werden, sondern die überschreibenden Methoden
in den Unterklassen. Beispiele für solche Klassen waren die
Sortierrelationen Relation in den Sortieralgorithmen oder auch die
Klasse ButtonLogic, mit der die Funktionalität eines GUIs
definiert wurde.
Java bietet ein weiteres Konzept an, mit dem Methoden ohne eigentliche
Implementierung deklariert werden können, die Schnittstellen.
Eine Schnittstelle sieht einer Klasse sehr ähnlich. Die
syntaktischen Unterschiede sind:
statt des Schlüsselworts class steht das
Schlüsselwort interface.die Methoden haben keine Rümpfe, sondern nur eine Signatur.
So läßt sich für unsere Klasse ButtonLogic eine
entsprechende Schnittstelle schreiben:
package de.tfhberlin.panitz.dialoguegui;
public interface DialogueLogic {
public String getDescription();
public String eval(String input);
}
Schnittstellen sind ebenso wie Klassen mit dem Javaübersetzer zu
übersetzen. Für Schnittstellen werden auch Klassendateien mit der
Endung .class erzeugt.
Im Gegensatz zu Klassen haben Schnittstellen keinen Konstruktor. Das
bedeutet insbesondere, daß mit einer Schnittstelle kein Objekt erzeugt
werden kann. Was hätte ein solches Objekt auch für ein Verhalten? Die
Methoden haben ja gar keinen Code, den sie ausführen könnten.
Eine Schnittstelle ist vielmehr ein Versprechen, daß Objekte Methoden
mit den in der Schnittstelle definierten Signaturen enthalten. Objekte
können aber immer nur über Klassen erzeugt werden.
Objekte, die die Funktionalität einer Schnittstelle enthalten, können
nur mit Klassen erzeugt werden, die diese Schnittstelle implementieren.
Hierzu gibt es zusätzlich zur extends-Klausel in Klassen auch
noch die Möglichkeit, eine implements-Klausel anzugeben.
Eine mögliche Implementierung der obigen Schnittstelle ist:
package de.tfhberlin.panitz.dialoguegui;
public class ToUpperCase implements DialogueLogic{
protected String result;
public String getDescription(){
return "convert into upper cases";
}
public String eval(String input){
result = input.toUpperCase();
return result;
}
}
Die Klausel implements DialogueLogic verspricht, daß in
dieser Klasse für alle Methoden aus der Schnittstelle eine
Implementierung existiert. In unserem Beispiel waren zwei
Methoden zu implementieren, die
Methode eval und getDescription().
Im Gegensatz zur extends-Klausel von Klassen können in
einer implements-Klausel auch mehrere Schnittstellen
angegeben werden, die implementiert werden.
Definieren wir zum Beispiel ein zweite Schnittstelle:
package de.tfhberlin.panitz.html;
public interface ToHTMLString {
public String toHTMLString();
}
Diese Schnittstelle verlangt, daß implementierende Klassen eine
Methode haben, die für das Objekt eine Darstellung als HTML erzeugen
können.
Jetzt können wir eine Klasse schreiben, die die beiden
Schnittstellen implementiert.
"+getDescription()
+ ""
+ "Small Gui application"
+ " for convertion of "
+ " a String into upper"
+ " case letters. "
+ "The result of your query was: