This content was uploaded by our users and we assume good faith they have the permission to share this book. If you own the copyright to this book and it is wrongfully on our website, we offer a simple DMCA procedure to remove your content from our site. Start by pressing the button below!
http://www.software-support.biz/ http://www.entwicklerpress.de/ Ihr Kontakt zum Lektorat und dem Verlag: [email protected] Bibliografische Information Der Deutschen Bibliothek Die Deutsche Bibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über http://dnb.ddb.de abrufbar.
Korrektorat: Petra Kienle Satz: text & form GbR, Carsten Kienle Umschlaggestaltung: Melanie Hahn Belichtung, Druck und Bindung: M.P. Media-Print Informationstechnologie GmbH, Paderborn. Alle Rechte, auch für Übersetzungen, sind vorbehalten. Reproduktion jeglicher Art (Fotokopie, Nachdruck, Mikrofilm, Erfassung auf elektronischen Datenträgern oder andere Verfahren) nur mit schriftlicher Genehmigung des Verlags. Jegliche Haftung für die Richtigkeit des gesamten Werks kann, trotz sorgfältiger Prüfung durch Autor und Verlag, nicht übernommen werden. Die im Buch genannten Produkte, Warenzeichen und Firmennamen sind in der Regel durch deren Inhaber geschützt.
Vorwort Wer heutzutage programmiert, wird das in der Regel objektorientiert tun. Objektorientierte Programmierung (kurz OOP) ist zwar weder die einzige Möglichkeit der Programmierung noch eine vollkommen neue Entwicklung. Aber so richtig durchgesetzt hat sich OOP erst Mitte der 90er Jahre. Und da heute nahezu alle (neueren) Programmierprojekte auf OOP selbst bzw. zumindest auf die Verwendung von Objekten setzen, müssen die Vorteile von OOP gravierend sein. In diesem Buch lernen Sie die Kernprinzipien der objektorientierten Programmierung und des dazugehörigen Umfelds kennen. Dabei profitieren von dem Buch einerseits die Leser, die bereits (erhebliche) Programmiererfahrung haben, aber keine oder nur wenig Erfahrung mit objektorientierter Programmierung. Dies sind in der Regel Entwickler, die prozedural programmieren (beispielsweise mit Cobol, Pascal, PHP, Perl oder ANSI-C). Diesen Programmierern fällt ein Umstieg auf die OOP oft nicht ganz leicht, da sie auf viele lieb gewonnene Techniken verzichten müssen1. Die andere Zielgruppe, die von der Lektüre des Buchs profitiert, besteht aus Schülern, Azubis und Studenten im Informatikumfeld bzw. allgemein Lesern, die sich interessiert oder professionell mit dem Computer auseinander setzen und grundsätzlich Programmieren lernen wollen. Für diese zweite Gruppe ist der objektorientierte Ansatz heutzutage ganz klar die erste Wahl. Für beide Gruppen gilt also, dass keine Erfahrung mit OOP vonnöten ist. Ich möchte allerdings in diesem Buch eine gewisse Erfahrung mit einfacher Programmierung voraussetzen, denn der Schwerpunkt in diesem Buch soll keinesfalls darauf liegen, elementare Programmierstrukturen wie Schleifen oder Kontrollanweisungen ausführlich zu behandeln. Als Programmiersprache zum Einstieg in die OOP gibt es nun mehrere Möglichkeiten: zum Beispiel Delphi, ObjectPascal, C/C++, Lisp, Smalltalk, C# oder auch Visual Basic. Hier im Buch wird jedoch Java verwendet. Java ist aus vielerlei Gründen eine ideale Sprache, um sowohl den Einstieg in die OOP zu schaffen als auch durch den Aufbau von Know-how in eine sehr zukunftsträchtige Technik zu investieren. Wer mit Java programmieren will, muss sich zu
1. Was ich sehr gut nachvollziehen kann, denn ich bin ein solcher Umsteiger, der allerdings bereits Mitte der 90iger-Jahre den Umstieg vollzogen hat.
Objektorientierte Programmierung in Java
9
Vorwort
100% auf OOP einlassen. Denn Java ist nicht hybrid1, wie es viele andere Programmiersprachen sind (etwa Visual Basic, Delphi oder C/C++). Und dennoch ist im Gegensatz zu anderen streng objektorientierten Programmiersprachen wie Smalltalk oder Lisp die Denkweise von Java nicht so radikal, dass ein Einstieg für prozedurale Programmierer extrem erschwert oder allgemein die Akzeptanz der Technik beeinträchtigt würde. Java als Sprache ist relativ einfach und das OOP-Konzept von Java ist so konsequent und logisch, dass damit der Einstieg in alle Techniken der OOP hervorragend möglich ist. Java ist zudem heutzutage eine der wichtigsten Programmiersprachen überhaupt und findet in unzähligen Gebieten der EDV Anwendung. Java ist extrem erfolgreich. Und dieser gigantische Erfolg hat verschiedene Ursachen. Da ist zuerst der Zeitpunkt, zu dem Java entwickelt und endgültig präsentiert wurde. Java profitierte zu Beginn vom damaligen Boom des WWW, denn diesem fehlten bis zum Auftreten von Java sowohl echte multimediale Darstellungs- und Animationsmöglichkeiten als auch die Möglichkeit einer vernünftigen Interaktion mit dem Anwender. Aber der Bedarf war einfach da und Java rannte mit seinen Applets und deren Multimedia- und Interaktionseigenschaften offene Türen ein. Ein weiterer Grund, warum Java bei seinem ersten Auftritt wie die Faust aufs Auge passte, war, dass man Anfang der 90er Jahre bereits auf einen großen Fundus an ausgereiften und bewährten Technologien als Ideenpool zurückgreifen konnte. Insbesondere kannte man die Fehler der Vorgänger und wusste, welche Probleme unter bestimmten Konstellationen auftreten. Java konnte mit diesen Erfahrungen im Hinterkopf vollkommen neu entwickelt werden. Als Resultat wurde Java so sicher und stabil, wie es heute geschätzt wird. Ein weiterer wichtiger Grund für den Triumph von Java ist die Plattformneutralität. Dabei ist die Bedeutung dieses Aspekts zyklisch zu bewerten. Am Anfang war es ein Knaller. Die These „Write once – run anywhere“ schlug ein. Dann aber zeigte sich der Preis dafür – die am Anfang sehr schwache Performance und teilweise unübliche Bedien- und Layoutfaktoren von grafischen Oberflächen. Kritiker meldeten sich zu Wort. Ein oft gehörtes Argument war: „Was brauche ich Plattformunabhängigkeit, wenn doch 95% aller Anwender
1. Als hybrid bezeichnet man Programmiersprachen, mit denen man nach verschiedenen Paradigmen, beispielsweise prozedural und objektorientiert, programmieren kann.
10
Vorwort
Windows einsetzen? Lieber ein schnelles Programm auf einer Plattform als eines, das die restlichen 5% aller Anwender auch noch abdeckt, aber niemanden richtig zufrieden stellt.“ Dieses Argument ist mittlerweile hinfällig. Zum einen sind mobile Endgeräte mit heterogener Architektur groß im Kommen. Dort ist eine plattformunabhängige Basis gefordert. Zum anderen wird die Hardware insgesamt immer schneller, wie auch Java immer weiter bezüglich seiner Performance Verbesserungen erfährt. Und damit sind die spürbaren Geschwindigkeitsunterschiede von Java-Applikationen zu nativen Applikationen nahezu nicht mehr relevant. Und nicht zuletzt gewinnen auch auf dem Desktop alternative Betriebssysteme wie Linux rasant an Bedeutung. Wenn eine Firma Software entwickelt, wird sie das in absehbarer Zukunft nicht nur für Windows tun müssen, sondern zumindest auch für Linux. Zwei oder mehr unterschiedliche Programme für den gleichen Zweck sind viel zu aufwendig. Java bietet die Lösung. Java ist also eine sehr zukunftsträchtige Technologie. Und um es nicht ganz unter den Tisch fallen zu lassen – OOP mit Java macht auch noch Spaß! Und diesen Spaß samt Erfolg beim Einstieg in die OOP mit Java wünscht Ihnen Ralph Steyer www.rjs.de
Objektorientierte Programmierung in Java
11
1
Bevor es losgeht
Einige wichtige Vorbemerkungen zu dem Buch Bevor es richtig losgeht mit dem Einstieg in die objektorientierte Programmierung (OOP) mit Java, sollen in diesem einleitenden Kapitel Dinge geklärt werden, die Ihnen die folgende Arbeit mit dem Buch und Java selbst erleichtern werden. Dies umfasst Hinweise zum Aufbau des Buchs sowie die notwendigen Voraussetzungen, um mit Java programmieren zu können.
1.1 Über das Buch Das Buch ist als Lehrbuch für den Einstieg in die objektorientierte Programmierung mit Java konzipiert. Es soll Ihnen sowohl beim Selbststudium helfen als auch Begleitmaterial dafür sein, in entsprechenden Kursen OOP mit Java zu lernen. Wie bereits im Vorwort erwähnt, wendet sich das Buch einerseits an Leser, die bereits Erfahrung1 mit prozeduraler Programmierung haben, aber keine oder nur wenig mit objektorientierter Programmierung, und andererseits an ambitionierte Einsteiger in die Programmierung, die diese gleich objektorientiert erlernen wollen und bisher nur wenig Programmiererfahrung haben. Insbesondere werden grundsätzlich keine Kenntnisse in Java oder einer anderen OO-Programmiersprache vorausgesetzt. Aber ganz bei Null möchte ich nicht anfangen. Ich setze beim Leser voraus, dass zumindest einfache Programme bereits erstellt wurden und der grundsätzliche Umgang mit einem Editor zur Erzeugung von Quelltext bekannt ist2. Die wichtigsten Details zum Umgang mit Java-Programmiertools zum Übersetzen des Quelltextes und zum Ausführen eines Java-Programms werden in diesem Buch jedoch erklärt. Bereits in diesem Kapitel finden Sie die Beschreibung für den grundsätzlichen Umgang mit den Java-Tools, wobei auf einige Details erst an späterer Stelle eingegangen wird, wenn das nötige Know-how zu Java aufgebaut wurde.
1. auch umfangreiche 2. Die Quelltexte der behandelten Beispiele stelle ich auf meiner Homepage unter www.rjs.de zum Download bereit. Allerdings rate ich dazu, die Beispiele selbst zu erstellen.
Objektorientierte Programmierung in Java
13
1 – Bevor es losgeht
1.1.1
Was in dem Buch behandelt wird
Im Buch erfahren Sie Grundsätzliches zur Idee objektorientierter Programmiersprachen und zu Objekten bzw. Klassen. Dies umfasst die Darstellung von Klassen sowie Details zu Attributen und Methoden. Da wir auf Java als konkrete Programmiersprache setzen, werden Sie dabei eine (dem Umfang des Buchs angemessene) Einführung in die Grundlagen der Programmierung mit Java sowie die wichtigsten Java-Techniken erhalten. Dies umfasst den generellen Programmaufbau bei Java (Klassenbildung) sowie Datentypen, Operatoren, Literale, Variablen und den Umgang mit verschiedenen Anweisungstypen (Kontrollflussanweisungen und Schleifen). Sowohl aus Sicht von Java als reiner Programmiertechnik als auch der OOP allgemein ist die Behandlung von Modifizierern elementar, wobei natürlich Java-Besonderheiten berücksichtigt werden (auch in Hinsicht auf Methoden bzw. Attribute). Fundamentale OOP-Techniken werden im Buch viel Raum einnehmen. Dies umfasst zunächst einmal allgemeine OO-Konzepte sowie die Gegenüberstellung des prozeduralen und des objektorientierten Programmieransatzes. Hinzu kommen vertiefende Themen wie Objekte als Instanzen von Klassen und die Aufgaben von Konstruktoren sowie Destruktoren. Datenkapselung, Nachrichten, Assoziationen, Vererbung, Polymorphie, abstrakte Klassen, Schnittstellen, Pakete und vertragbasierte Programmierung sind weitere Schlagwörter, die detailliert ausgearbeitet werden. Wir verfolgen zusätzlich Grundlagen der objektorientierten Philosophie, die über die eigentliche Kodierung von Quelltext hinausgehen. Dies betrifft Konzepte der Softwarearchitekturen und allgemeine Abläufe der objektorientierten Softwareentwicklung (OOA, OOD, OOP) inklusive der OOP-Methodologien und OOP-Modellierung. Wir behandeln dabei – zumindest am Rande – UML sowie Vorgehensmodelle der Softwareentwicklung (Wasserfallmodell, Mauerfallmodell, inkrementelle Softwareentwicklung, Spiralenmodell). Ein kurzer Ausblick auf weitere interessante Java-Techniken schließt das Buch ab.
1.1.2
Was Sie unbedingt brauchen
Es gibt ein paar Dinge, die Sie unbedingt brauchen, wenn Sie mit dem Buch erfolgreich OOP mit Java lernen und dann auch in der Praxis anwenden wollen.
14
Über das Buch
Die Hardware Natürlich ist ein Computer notwendig – möglichst mit Internetzugang, denn viele Quellen und Tools sind über das Internet zugänglich. Für die meisten Leser wird dabei ein PC mit Intel-kompatibler Architektur die bevorzugte Wahl darstellen. Darauf werden wir uns bei eventuellen Ausführungen beschränken. Aber grundsätzlich laufen Java-Programme auf allen wichtigen Computerplattformen. Dies ist explizit ein Highlight von Java. Ihr Computer sollte jedoch nicht zu schwach auf der Brust sein. Denn Java (und insbesondere der Einsatz integrierter Entwicklungsumgebungen – kurz IDE1) fordert doch etwas Power.
Das Betriebssystem Als Betriebssystem zum Ausführen von Java-Applikationen können Sie jedes System einsetzen, für das es eine virtuelle Java-Maschine (JVM – Java Virtual Machine) gibt2. Für die Entwicklung bieten sich jedoch zwei Betriebssysteme an, die gegenüber anderen herausragen und für die vor allem die Java-Entwicklungstools verfügbar sind. Dabei handelt es sich um Unix bzw. Linux oder ein anderes Unix-Derivat und Windows (ab Version 95). Die Betriebssystemplattformen unterscheiden sich in den von uns nachfolgend behandelten Techniken jedoch nicht signifikant. Dies ist ja gerade ein Charakteristikum von Java. Sie sollten nur eine halbwegs neue Betriebssystemversion verwenden. Im Grunde ist jedes Betriebssystem geeignet, das nach dem Jahr 1995 eingeführt wurde. Am besten arbeiten Sie jedoch mit einem Betriebssystem, das nach dem Jahr 2000 eingeführt wurde (etwa Windows XP).
Das JDK und die JRE Als Programmierumgebung werden in dem Buch nur ein beliebiger Texteditor und das Java-SDK (Software Development Kit) vorausgesetzt. Das Java-SDK ist dabei in einem etwas umfangreicheren Kontext zu sehen. Java ist eine objektorientierte, plattformneutrale Programmiersprache, die auf dem Vorhandensein einer ganzen Plattform mit einer Laufzeitumgebung, zahlreichen Tools
1. Eine IDE (Integrated Development Enviroment) bezeichnet eine Programmierumgebung mit Projektverwaltung, grafischer Erstellung von Quellcode, Fehlersuchmöglichkeit usw. 2. Die genaue Erklärung der JVM folgt natürlich noch.
Objektorientierte Programmierung in Java
15
1 – Bevor es losgeht
und bereits verfügbaren Softwarebibliotheken (so genannten Paketen) basiert. Kern der Plattform sind die JVM und zahlreiche Basissoftwarepakete, die Ihnen bereits von Sun selbst zur Erstellung und zum Ausführen von stabilen, sicheren und leistungsfähigen Programmen zur Verfügung gestellt werden. Die JVM einschließlich der Basispakete wird als JRE (Java Runtime Environment) bezeichnet. Das ist die Java-Laufzeitumgebung, die jeder Anwender auf seinem Computer braucht, um ein Java-Programm laufen zu lassen. Diese JavaLaufzeitumgebung gibt es für nahezu alle wichtigen Betriebssysteme. Wenn Sie Java-Programme schreiben und testen wollen, brauchen Sie dementsprechend ebenfalls die JRE. Diese allein genügt jedoch nicht, denn Sie benötigen ebenso Entwicklungstools. Aber selbstverständlich braucht kein Anwender spezifische Entwicklungstools, wenn er nur Java-Programme ausführen will. Die JRE besteht neben der JVM und den Java-Kernklassen aus einigen Tools, die rein zur Ausführung von Java-Code dienen. Das wichtigste Tool ist ein Java-Interpreter. Hinter der Bezeichnung JDK (Java Development Kit), das zum Zeitpunkt der Bucherstellung in der Version 1.5 vorliegt, verbirgt sich der wesentlichste Bestandteil der so genannten Java 2 Platform Standard Edition 5.0 (J2SE 5.0) bzw. des Java-SDK. SDK steht dabei für eine Art Obermenge des JDK. Dieses JDK benötigen Sie, um Java-Programme zu erstellen1, wobei die JDK-Tools alle auf Befehlszeilenebene zu verwenden sind, wenn Sie diese direkt einsetzen wollen. Eine aktuelle Version des JDK bzw. SDK können Sie kostenlos von der Internetseite http://java.sun.com/ des Java-Portals von Sun laden. Dazu finden Sie dort diverse ergänzende Features, die zur Java-2-Plattform zählen oder sie ergänzen – angefangen mit einer Dokumentation, deren Download und Installation unbedingt zu empfehlen ist, über ältere Versionen des JDK bis hin zu einer reinen Laufzeitumgebung. Beachten Sie, dass mit dem JDK zwar plattformneutrale Java-Programme erstellt werden können, Sie das JDK aber speziell für Ihre Plattform benötigen. Und da das JDK von Sun nur für Unix einschließlich wichtiger Derivate wie Linux sowie Windows bereitgestellt wird, erklärt sich damit die Wahl des Betriebssystems als Entwicklungsplattform. In der Praxis ist es normalerweise ebenfalls nur sinnvoll, mit einer Finalversion eines JDK zu arbeiten. Zwar werden immer wieder Betaversionen zum Download
1. Genauer: übersetzen, debuggen usw.
16
Über das Buch
bereitgestellt, aber mit diesen sollte man höchstens experimentieren. Für das, was wir in dem Buch besprechen, ist es nicht besonders relevant, ob Sie über das neueste JDK verfügen1. Es sollte sich jedoch mindestens um die Version 1.3 oder eine Folgeversion handeln.
Die Installation und der Umgang mit dem JDK Die Installation des JDK ist vollkommen unproblematisch2. Folgen Sie einfach den Schritten des Assistenten. Das JDK besteht aus einer ganzen Reihe von Programmen, die sich nach der Installation im Unterverzeichnis bin des JDKInstallationsverzeichnisses befinden.
Abb. 1.1: Die Tools des JDK im Verzeichnis bin – hier unter Windows
1. Außer bei einer Technik – den generischen Klassen. Dafür benötigen Sie das JDK 1.5 oder neuer. 2. Das galt für ältere Versionen des JDK nicht unbedingt, aber seit dem JDK 1.3 und spätestens dem JDK 1.4 gibt es keine nennenswerten Probleme mehr.
Objektorientierte Programmierung in Java
17
1 – Bevor es losgeht
Dort können Sie sie über die Befehlszeilenebene mit eventuell benötigten Parametern aufrufen.
Abb. 1.2: Aufruf von JDK-Tools auf Befehlszeilenebene
Wenn Sie ein Programm des JDK ohne Parameter aufrufen, erhalten Sie in der Konsole Hinweise zu den optionalen und ebenso zu den zwingenden Parametern. Die wichtigsten Tools des JDK waren schon in den ersten Versionen dabei, aber über die Entwicklung des JDK kamen laufend neue dazu. Aber nicht nur das. Es wurden auch immer wieder Tools in neuen Versionen beseitigt, weil sie nicht so funktionierten wie geplant oder weil sie nicht mehr benötigt wurden. Grundsätzlich werden Sie für die meisten Aufgaben beim Einstieg in Java aber nur die nachfolgend beschriebenen Programme benötigen (im engeren Sinn die Basisprogramme des JDK).
Der Compiler Der Java-Compiler mit Namen javac kompiliert die als Parameter angegebene(n) Quelltextdatei(en) mit der Dateierweiterung .java in Java-Bytecode. Für jede Klassendefinition in einer Quelltextdatei wird eine eigene BytecodeDatei angelegt. Eine solche Bytecode-Datei hat immer die Erweiterung .class. Der Namensstamm der erzeugten Datei(en) wird aus dem Namen der Klasse(n) (nicht dem Dateinamen der Quelltextdatei) übernommen.
Der Interpreter Der Java-Interpreter mit Namen java ist ein Kommandozeileninterpreter zur Ausführung von eigenständigen Java-Anwendungen. Die das Programm repräsentierende Klassendatei (die Datei mit dem Bytecode) wird dem Interpre-
18
Über das Buch
ter als Parameter übergeben. Dabei darf die Dateierweiterung .class nicht angegeben werden. Als Alternative zum Interpreter java gibt es einen Zwilling namens javaw. Dieser unterscheidet sich von java nur darin, dass Fehlermeldungen, die von java in der Konsole ausgegeben werden, umgeleitet oder unterdrückt werden können. Er kommt oft bei Endkunden zum Einsatz, während java in der Regel vom Entwickler verwendet wird.
Der Appletviewer Der Appletviewer ist eines der wenigen Programme des JDK mit grafischer Oberfläche. Er ermöglicht das Ausführen eines Java-Applets. Das Tool benötigt dazu als Parameter beim Aufruf nur den URL einer rudimentären HTMLDatei1. Diese dient als Gerüst, das ausschließlich ein HTML-Tag mit Angabe des auszuführenden Applets sowie eventuell benötigten Parametern enthalten muss. Der Appletviewer lädt alle Applets innerhalb der HTML-Datei und führt sie – sofern es mehrere Applets gibt – jeweils in einem separaten Fenster aus. Alle anderen HTML-Anweisungen in der Webseite werden ignoriert.
Das Dokumentations-Tool und die Java-Dokumentation Das Dokumentations-Tool javadoc ist eines der interessantesten und wahrscheinlich auch wichtigsten Programme des JDK. Daraus können Sie auf der Basis von speziellen Kommentar-Tags innerhalb einer Java-Quelldatei sowie den eigentlichen Quellcodeelementen eine lesbare Dokumentation einer Datei oder eines Pakets erzeugen. Dabei steht das javadoc-Tool in einem größeren Kontext: der Dokumentation eines Programms bzw. eines ganzen Projekts. Viele Einsteiger in die Programmierung gehen davon aus, dass ein Programmierprojekt mit der Erstellung eines Programms abgeschlossen ist. Doch die Erstellung eines Programms ist nur ein Teil des Projekts. Dieser stellt zudem in der Regel den leichtesten Part mit dem geringsten Aufwand dar. Die hauptsächliche Arbeit in einem Programmierprojekt wird neben der Konzeption normalerweise die Fehlersuche und vor allem die spätere Wartung sein. Es gibt Untersuchungen, wonach die Wartung von Programmen um ein Vielfaches aufwändiger als die Erstellung ist. Vor allem schätzt es kein Programmierer, wenn er ein Programm übernehmen und erst Stunden oder gar Tage damit ver-
1. Beachten Sie, dass die HTML-Datei mit der Endung einzugeben ist!
Objektorientierte Programmierung in Java
19
1 – Bevor es losgeht
bringen muss, dessen Strukturen halbwegs zu verstehen. Bei der Wartung eines Programms ist man ohne gute Dokumentation des Projekts ziemlich aufgeschmissen. Sun stellt nun mit der Dokumentation des Java-APIs bereits ein geniales Hilfsmittel zur Java-Programmierung bereit. Wenn Sie die Dokumentation zu Java und dem JDK auf Ihrem Rechner installiert haben, wird innerhalb des Unterverzeichnisses docs eine verlinkte HTML-Dokumentation des gesamten Java-APIs aufgebaut, die Sie über einen gewöhnlichen Webbrowser verwenden können. Der Einstieg in die Dokumentation erfolgt über die Datei index.html. Wenn Sie diese öffnen, gelangen Sie zur Hauptübersicht der Dokumentation.
Abb. 1.3: Die Indexdatei der Java-Dokumentation
Unter der Hauptübersicht der Dokumentation sind sowohl lokale Verweise als auch URLs zu Internetquellen zu finden. Der Schwerpunkt der interessanten Informationen ist lokal. Die Dokumentation ist in verschiedene Rubriken
20
Über das Buch
unterteilt. Es gibt am Anfang eine Rubrik mit allgemeinen Informationen zu Neuigkeiten, Bugs usw. Interessant wird es direkt darunter, denn dort finden Sie unter der Rubrik API & Language Documentation Java 2 den Link Platform API Specification. Dahinter verbirgt sich die Dokumentation des gesamten Java-APIs.
Abb. 1.4: Die API-Spezifikation in einem dreigeteilten Browser-Fenster
Wenn Sie nun selbst eine analoge Dokumentation Ihres eigenen Java-Projekts erstellen wollen, hilft Ihnen javadoc durch eine weitgehend automatische Erstellung der Dokumentation gewaltig. Wenn Sie wissen wollen, wie diese aussehen kann, nehmen Sie als Beispiel die Dokumentation des Java-APIs selbst. Die gesamte Dokumentation des Java-APIs ist mit Hilfe von javadoc weitgehend automatisiert entstanden und Sie können das Tool für Ihre eigenen Projekte verwenden. Vereinfacht ausgedrückt schreibt javadoc die innerhalb der Datei in speziellen Kommentaren eingeschlossenen Texte und die Quelltextstrukturen in eine lesbare Datei (etwa eine HTML-Datei). Die so genann-
Objektorientierte Programmierung in Java
21
1 – Bevor es losgeht
ten javadoc-Kommentare werden wir bei der Behandlung von Java als Sprache genauer besprechen. Allgemein geben Sie beim Aufruf von javadoc einfach nur die zu dokumentierenden Dateien oder Pakete als Parameter an. Wenn Sie genauere Angaben machen wollen, ist oft die ergänzende Angabe von Sichtbarkeitsmodifizierern sinnvoll.
Der Debugger Der Java-Debugger jdb hilft Ihnen bei der Fehleranalyse. Aber da jdb ein über Befehlszeileneingaben zu steuerndes Tool ist, ist der Umgang damit recht kompliziert und unkomfortabel. Es handelt sich nicht um einen integrierten Debugger, wie er mittlerweile in den meisten Entwicklungsumgebungen üblich ist, sondern er wird über Befehlszeileneingaben gesteuert. Wenn Sie sich in die aufwändige Syntax und Logik des Tools eingearbeitet haben, zeigt es seine Zähne. Debuggen unter Java mithilfe eines Tools ist im Grunde nur im Rahmen einer IDE sinnvoll. Diese ruft implizit jdb selbst oder einen eigenen Java-Debugger auf.
Der Disassembler Eher ein erweitertes, aber dennoch sehr interessantes Basisprogramm des JDK ist der Java-Disassembler javap. Mit diesem Tool können Sie aus kompiliertem Java-Bytecode den ursprünglichen Quellcode wieder weitgehend reproduzieren1. Dazu übergeben Sie – ohne Dateierweiterung – den Namen der Klassendatei, deren Quelltext Sie reproduzieren wollen. Bei der Reproduktion werden zwar nicht alle Details wiederhergestellt, aber die wichtigsten Bestandteile und Strukturen des Quellcodes. Nach der Rückübersetzung des Codes werden verschiedene Informationen über den Quelltext ausgegeben. Mit diversen Optionen kann man diesen Vorgang genauer spezifizieren.
Das Java Archive Tool Ein anderes erweitertes Basisprogramm des JDK ist das Archivierungsprogramm mit Namen jar. Damit können Sie eine beliebige Anzahl von Dateien zu einem einzigen und vom Java-System direkt nutzbaren komprimierten Java-
1. Diesen Vorgang nennt man Disassemblierung.
22
Über das Buch
Archiv mit der Erweiterung .jar packen (jar steht für Java Archive). JAR-Archive basieren auf der ZIP-Technologie und dem ZLIP-Format und sind dazu binärkompatibel1.
Java-IDEs Nun enthält das Java-SDK selbst keinen Editor, aber bei jedem Betriebssystem wird ein reiner Texteditor mitgeliefert. Zur Erstellung von Quellcode benötigt man zwar bei Java (wie bei den meisten Programmiertechniken) als Minimalausstattung in der Tat nur so einen Editor. Jedoch sind dessen quasi nicht vorhandene Unterstützung und die Arbeit mit den oben beschriebenen Befehlszeilen-Tools des JDK sicherlich nicht jedermanns Sache und es zeigen sich auch einige Nachteile. Rein zum Lernen ist so eine Programmierung mit Editor und dem Java-SDK auf Befehlszeilenebene sicher sehr sinnvoll. Eine Programmierumgebung mit verschiedenen Ansichten, grafischer bzw. unterstützter Erstellung von Quellcode, farblicher Hervorhebung von Strukturen, Fehlersuchmöglichkeit usw. in Form einer IDE ist allerdings in der Praxis und vor allem im professionellen Programmierumfeld nicht mehr wegzudenken. Bei der Reduzierung auf einen reinen Editor und das JDK haben Sie grundsätzlich sehr viel Tipparbeit und Sie müssen alle Befehle in ihrer exakten Schreibweise mit sämtlichen Parametern usw. genau kennen. Insbesondere Programmierer, die permanent zwischen verschiedenen Programmiersprachen wechseln oder neu in einer Programmiersprache sind, bringen „gerne“ Sprachen durcheinander. Didaktisch erscheint das Begehen von Fehlern zwar sehr sinnvoll, aber in der Praxis ist es auf Dauer nervig. Insbesondere hat ein Programmierer viel Arbeit bei Standardvorgängen, z.B. beim Erzeugen einer grafischen Benutzeroberfläche oder beim Zugriff auf Datenbanken und der Darstellung von Abfrageergebnissen. Warum soll ein Programmierer im Grunde triviale Dinge manuell eingeben, wenn diese sich doch strukturell immer wiederholen und eine IDE das besser und schneller kann? Es ist sinnvoller, wenn sich ein Programmierer auf die Dinge konzentriert, die ein Codeassistent nicht kann. Überhaupt haben Sie beim Verzicht auf eine IDE keinerlei Hilfe bei der Eingabe des Quelltextes oder auch bei der Fehlersuche. Gerade die Fehlersuche ist mit einer IDE um Welten bequemer als mit einem textorientierten und/
1. Sie können eine .jar-Datei einfach in eine .zip-Datei umbenennen und umgekehrt.
Objektorientierte Programmierung in Java
23
1 – Bevor es losgeht
oder externen Debugger. Gravierend ist desgleichen die erhebliche Gefahr von Tippfehlern, wenn Ihnen ein intelligenter Editor nicht auf die Finger schaut. Besonders wenn Sie zu logischen Fehlern führen. Zudem müssen Sie zum Testen einer Applikation das Erstellungsprogramm verlassen.
Abb. 1.5: Eclipse ist eine mächtige OpenSource-IDE, die sich hervorragend zur Erstellung von Java-Programmen eignet
Mit einer IDE vermeiden Sie diese Probleme. Wenn die IDE gut ist, erhalten Sie so umfangreiche Hilfe bei Standardvorgängen, dass oft wenige Mausklicks und ein paar Eingaben im Assistenten genügen und Sie verfügen bereits über ein umfangreiches Programm im Grundgerüst. Natürlich birgt der Einsatz von IDEs auch einige Probleme. Das beginnt damit, dass viele Tools im Java-Umfeld richtig teuer sind. Mittlerweile gibt es jedoch
24
Über das Buch
im OpenSource-Bereich sehr mächtige IDEs, die dieses Argument entkräften können1. Allerdings sind IDEs – gerade, wenn sie sehr leistungsfähig sind – in der Bedienung oft nicht trivial und werden Einsteiger am Anfang überfordern. Zudem erfordern sie eine adäquate Hardware. Und auch im professionellen Umfeld muss man sich auf jeden Fall Gedanken um die vernünftige Auswahl einer IDE für den endgültigen Einsatz machen. Gerade IDEs mit unterstützender Codeerstellung generieren oft spezifischen Code (der möglicherweise auch nicht optimiert ist) und verlangen insbesondere eine solche Codestruktur, wenn bestehender Quellcode weiterverarbeitet werden soll. Das führt dazu, dass Sie schwer von einem Tool auf ein anderes Tool wechseln können. Wenn Ihnen nach einer gewissen Zeit ein Tool nicht mehr gefällt, können Sie nicht einfach so ein anderes Programm nehmen. Besonders schwierig wird die Situation, wenn der Hersteller einer IDE Pleite geht und keinen Support mehr leistet. Wenn Sie nun die Vor- und Nachteile bezüglich einer IDE abwägen, überwiegen aus meiner Sicht in der Praxis die Vorteile massiv. Allerdings sollte ein Programmierer im Prinzip auch ohne die Hilfe einer IDE auskommen. Oder um es anders auszudrücken: Eine IDE sollte nur der Programmierer verwenden, der sie im Grunde nicht benötigt. Das heißt im Umkehrschluss: Zum Lernen von Java und OOP sollten Sie auf den Einsatz (zumindest am Anfang) weitgehend verzichten.
1.1.3
Über den Autor
Zum Abschluss dieses einleitenden Kapitels möchte ich mich Ihnen noch kurz vorstellen. Ich bin Diplom-Mathematiker und habe nach dem Studium einige Jahre bei einer großen Versicherung im Rhein-Main-Gebiet als Programmierer gearbeitet. Dort habe ich mit Turbo Pascal und C/C++ DOS-Programme für die Unterstützung des Außendiensts erstellt und an der Planung von Daten1. Eine sehr weit verbreitete OpenSource-IDE ist Eclipse (http://www.eclipse.org). Eclipse ist selbst in Java programmiert und kann zur Java-Programmierung verwendet werden. Eclipse setzt auf einem bereits installierten JDK auf. Genau genommen ist Eclipse aber keine JavaIDE, sondern ein Framework zur Integration verschiedenster Anwendungen. Und natürlich existieren eine ganze Reihe von alternativen Java-Tools, etwa der JCreator, die NetBeans IDE oder kommerzielle Tools wie den JBuilder, den es mittlerweile auch in einer OpenSource-Variante gibt.
Objektorientierte Programmierung in Java
25
1 – Bevor es losgeht
banken mitgewirkt. Insbesondere die Zweigleisigkeit von Turbo Pascal als prozedurale Technik (unsere damalige Kerntechnik) und einzelne Projekte in C/C++ mit objektorientiertem Ansatz ließen mich alle Schwierigkeiten erfahren, die bei einem Umstieg von der prozeduralen Denkweise auf die objektorientierte Denkweise wohl möglich sind ;-((. So richtig verinnerlicht habe ich den objektorientierten Denkansatz erst mit Java. Mit Java wurde ich bereits relativ früh nach dessen Einführung konfrontiert (1996) und da dort ausschließlich objektorientiert gearbeitet wird, kann man Java nur dann programmieren, wenn man sich vollständig auf die Denkweise einlässt1. Seit 1995 bin ich nicht mehr angestellt, sondern verdinge mich als Freelancer. Dabei teilt sich mein Tätigkeitsfeld in Fachautor, Fachjournalist, EDV-Dozent und Programmierer. Das macht aus meiner Sicht einen guten Mix aus, bewahrt mich vor Langeweile und hält mich sowohl in der Praxis als auch am Puls der Entwicklung. Und ich bin deshalb so frei, mich aufgrund meiner Erfahrung für geeignet zu betrachten, ein Buch für Ein-/Umsteiger zu schreiben, die sich schmerzhafte Erfahrungen beim Ein-/Umstieg in die OOP ersparen wollen.
1. Ich habe den Umstieg ohne Hilfe und geeignete Literatur vollzogen und das tat sehr weh. Aber ein bisschen Starrsinn scheint allen Programmierern eigen und ganz hilfreich zu sein ;-).
26
2
Die Idee objektorientierter Programmierung
Dieses Kapitel führt Sie in einer ersten Stufe in die fundamentalen theoretischen Konzepte, Ideen und Methoden der objektorientierten Denkweise ein. Wenn Sie objektorientiert programmieren wollen, benötigen Sie schon zu Beginn vor allem ein Verständnis für die elementarsten Kernprinzipien. Die reine Syntax in einer spezifischen OO-Sprache wie Java, C/C++ oder C# ist erst einmal zweitrangig. Insbesondere muss klar sein, was eigentlich ein Objekt darstellt, wie Klassen dazu in Beziehung stehen, wie Klassen untereinander in Beziehung stehen und wie konkret Objekte erzeugt und wieder entfernt sowie verwendet werden können und warum sich OOP durchgesetzt hat und wie sie sich von klassischer Programmierung abgrenzt. Dieses Wissen vermittelt Ihnen das vorliegende Kapitel. Dabei werden an dieser Stelle noch keine praktischen Ansätze mit Hilfe einer spezifischen Programmiersprache wie Java und beileibe noch nicht alle Aspekte der OOP besprochen. Stattdessen werden unabhängig von der konkreten praktischen Anwendung nur die notwendigen Begriffe und Denkweisen erläutert, ohne deren Verständnis Sie überhaupt nicht sinnvoll mit der Java-Programmierung (oder der Programmierung in einer anderen OO-Sprache) beginnen können. Allerdings müssen Sie beim ersten Lesen nicht alle Ausführungen bis ins Detail verstehen. Bei der Lektüre der nachfolgenden Kapitel werden sich viele Dinge aus der Praxis erschließen und Sie können dann noch einmal zu diesem Kapitel zurückkehren. Vertiefende Kenntnisse der OOP folgen dann sowohl beim praktischen Einstieg in Java (nächstes Kapitel) als auch in Verbindung mit weiteren praktischen Java-Anwendungen in den darauf folgenden Kapiteln.
2.1 Objektorientierte Programmiersprachen Die Geschichte der Programmierung reicht in ihren theoretischen Ansätzen bis zum Beginn des 20. Jahrhunderts zurück. Allerdings begann die praktische Umsetzung in der heute darunter verstandenen Form erst nach den Arbeiten von Konrad Zuse und der Von-Neumann-Maschine um 1945. Seit dieser Zeit wurden eine große Anzahl verschiedener Programmiersprachen entwickelt,
Objektorientierte Programmierung in Java
27
2 – Die Idee objektorientierter Programmierung
die sich vielfach bestimmten Sprachfamilien zuordnen lassen. So zählen die heute wichtigsten Programmiersprachen von der Syntax her meist zur C-Familie, die wiederum auf eine Sprache namens Algol zurückgeht. Programmiersprachen werden aber nicht nur bezüglich der Syntax klassifiziert, sondern ebenfalls historisch in verschiedene Generationen eingeteilt:
쐌 Die historisch erste Generation ist die Maschinensprache in all ihren Ausprägungen. Diese Programmiersprache bezeichnet direkte Prozessorbefehle über numerische Binärcodes. Maschinensprache ist explizit plattformabhängig. 쐌 Als zweite Generation wird Assembler gesehen. Assembler verwendet anstelle von numerischen Binärcodes symbolische Bezeichner (Mnemonics) für Anweisungen, die in genau einen Maschinenbefehl umgesetzt werden. Auch Assembler ist plattformabhängig. 쐌 Die dritte Generation umfasst die höheren Programmiersprachen. Diese unterstützen erstmals Algorithmen und die Verwendung von Schlüsselwörtern, die der englischen Sprache entliehen sind. Höhere Programmiersprachen sind weitgehend anwendungsneutral und plattformunabhängig. Diese Generation der Programmiersprachen hat ihre Geburtsstunde Ende der fünfziger Jahre mit Fortran, Cobol und Algol60. Später kamen unter anderem Pascal, Modula-2, C und Basic hinzu. 쐌 Die vierte Generation der Programmiersprachen bezeichnet anwendungsbezogene (applikative) Sprachen. Solche Programmiersprachen ergänzen Techniken der dritten Generation um Sprachmittel für relativ komplexe, anwendungsbezogene Operationen. Dies sind beispielsweise Zugriffe auf Datenbanken (zum Beispiel mit SQL1) oder die Gestaltung von grafischen Benutzeroberflächen (GUI2). 쐌 Als fünfte Generation der Programmiersprachen werden solche Sprachen bezeichnet, die das relativ neutrale Beschreiben von Sachverhalten und Problemen erlauben und den genauen Problemlösungsweg nicht exakt vorgeben. Im Rahmen der künstlichen Intelligenz setzt man diese Sprachen ein.
1. SQL ist heute die Abkürzung für Standard Query Language. Früher wurde das S für Structured verwendet, weshalb man in der Literatur beide Varianten findet. 2. Das steht für Graphical User Interface.
28
Bessere Softwarequalität durch Objektorientierung
Wenn Sie sich nun die Sprachgenerationen ansehen, werden Sie sich fragen, wo objektorientierte Sprachen einzusortieren sind? Die Antwort ist einfach – sie passen nicht exakt in dieses chronologisch orientierte Generationenmodell1. Sie werden daher oft als eigenständige OO-Generation bezeichnet, die sich bis Anfang der 70er Jahre und teilweise noch weiter zurückverfolgen lässt. Sehr frühe Vertreter mit objektorientiertem Denkansatz sind Simula 67 oder Logo. In den 70er Jahren entstand als wichtigster Vertreter erster objektorientierter Sprachen Smalltalk, das sich in mehreren Zyklen entwickelte und als einer der geistigen Väter von Java gilt. In den 80er Jahren entstanden mit Objective C (ObjC), C++, Eiffel oder ObjectPascal mehrere OO-Sprachen, die ein bestehendes, nicht objektorientiertes Konzept um objektorientierte Techniken erweiterten. Zum Beispiel wurde C um C++ erweitert und tritt seitdem meist als Paar auf (C/C++). C++ gibt es in der praktischen Anwendung nicht als eigenständige, von C vollkommen losgelöste Sprache. Seit Anfang der 90er Jahre entstanden eine Reihe moderner, eigenständiger Programmiersprachen, die sich ausdrücklich als rein objektorientierte Programmiersprachen verstehen und mit prozeduralen Erblasten vollkommen brechen. Java zählt explizit dazu, ebenso wie die meisten Sprachen unter dem .NET-Konzept von Microsoft wie C#. Diese Programmiersprachen unterstützen im Allgemeinen die wichtigsten Konzepte der objektorientierten Programmierung – allerdings in unterschiedlichem Maße.
2.2 Bessere Softwarequalität durch Objektorientierung Das zentrale Problem bei der Entwicklung komplexer Softwaresysteme ist, eine möglichst hohe Softwarequalität zu erreichen. Allgemein betrachtet man dabei sowohl die innere als auch die äußere Softwarequalität. Die innere Softwarequalität bezeichnet die Sicht des Entwicklers. OOP bietet durch die Möglichkeiten der Abstraktion, Hierarchiebildung, Kapselung von Interna, Wiederverwendbarkeit, Schnittstellenbildung und einige weitere Techniken hervorragende Ansätze zur Gewährleistung einer hohen inneren Softwarequalität. Die äußere Softwarequalität spiegelt die Sicht des Kunden wider. Dieser erwartet Dinge wie Korrektheit, Stabilität, Anwenderfreundlichkeit oder Er1. Wenn man die Generationen als Entwicklungsstufen interpretiert, passen die objektorientierten Sprachen allerdings einigermaßen zur 3. Generation.
Objektorientierte Programmierung in Java
29
2 – Die Idee objektorientierter Programmierung
weiterbarkeit einer Software. Ein Paradigma der OOP ist, dass eine hohe innere Softwarequalität zu einer hohen äußeren Softwarequalität führt.
2.3 Kernkonzepte der Objektorientierung Die Kernkonzepte in der objektorientierten Softwareentwicklung sind folgende1:
쐌 Objekte als Abstraktion eines realen Gegenstands oder Konzepts mit einem spezifischen Zustand und einem spezifischen Verhalten. 쐌 Klassen als Baupläne für Objekte, die Objekttypen repräsentieren. 쐌 Attribute als Beschreibung objekt- und klassenbezogener Daten. 쐌 Methoden als Beschreibung objekt- und klassenbezogener Funktionalität. 쐌 Assoziationen und Aggregationen als Mechanismen zum Ausdruck von Objektbeziehungen. 쐌 Vererbung als Mechanismus zum Generalisieren und Spezialisieren von Objekttypen. 쐌 Polymorphie zur flexiblen Auswahl geeigneter Methoden identischen Namens anhand des Objekttyps und der Argumentenliste. 쐌 Schnittstellen als Mechanismus zur Strukturierung von Klassenbeziehungen sowie abstrakte Klassen als Mechanismus zum Erzwingen bestimmter Operationen in spezialisierten Klassen. 쐌 Generische Klassen zur Darstellung von Klassenfamilien. In diesem Kapitel besprechen wir die theoretischen Konzepte für Objekte und Klassen, Attribute und Methoden, Assoziationen und Aggregationen, vertragsbasierte Programmierung sowie Vererbung. In Kapitel 3 und 4 werden diese mit Java anschaulich angewendet. Die anderen Kernprinzipien der objektorientierten Programmierung werden in Kapitel 5 behandelt, wobei dort konkret Java den theoretischen Ansatz begleitet.
1. Die Begriffe sollen an dieser Stelle nur als Schlagwörter eingeführt werden.
30
Objekte und Klassen
2.4 Objekte und Klassen In der OOP wird alles, was Sie jemals im Quelltext notieren, als Objekt bzw. Klasse oder ein Objekt-/Klassenbestandteil zu verstehen sein. Das gilt nicht für so genannte hybride Sprachen, mit denen man zwar objektorientiert programmieren kann, die jedoch mit ihrer prozeduralen Vergangenheit nicht vollständig gebrochen haben. Als Beispiele seien C/C++, Delphi oder Visual Basic genannt. Dort finden Sie auf Grund der historischen Altlasten Techniken, die nicht in das OO-Konzept passen und die dort parallel zu OO-Techniken verwendet werden können. So genannte objektbasierende Sprachen wie JavaScript verwenden Objekte, realisieren aber die verbindlichen Konzepte der OOP nur teilweise. Aber versuchen wir erst einmal zu klären, was Objekte sind.
2.4.1
Objekte
Wenn Sie einen (realen) Kugelschreiber in die Hand nehmen, können Sie die Eigenschaften dieses (realen) Objekts beschreiben – seine Form, die Größe, die Farbe. Sie können alle Eigenschaften (Attribute) des Objekts aufführen, soweit es notwendig bzw. sinnvoll ist. Und Sie können die aktiven Fähigkeiten des Stifts beschreiben, soweit auch diese notwendig bzw. sinnvoll sind (Methoden) – etwa, dass die Mine eines Kugelschreibers herausgedrückt und wieder eingefahren werden kann. Dabei ist es offensichtlich, dass eine Mine nur dann herausgedrückt werden kann, wenn sie zu diesem Zeitpunkt eingefahren ist und umgekehrt. Und ebenso klar ist, dass Sie mit dem Kugelschreiber nur dann schreiben können (eine aktive Fähigkeit), wenn die Mine vorher ausgefahren wurde. Offensichtlich kann sich ein reales Objekt in einem spezifischen Zustand befinden und bestimmte Dinge in Abhängigkeit davon bereitstellen oder deaktivieren. Die objektorientierte Denkweise im Computerumfeld entspricht vollkommen dem Abbild der realen Natur und den Objekten, die wir dort wahrnehmen. Die Objektorientierung versucht einfach, diese aus der realen Welt so natürlichen Vorstellungen in die EDV zu übertragen.
Objektorientierte Denkweise in der Benutzerführung Wenn Sie sich den Desktop einer grafischen Betriebssystemoberfläche ansehen, repräsentieren alle dort zu findende Symbole Objekte. Ein Objekt wird
Objektorientierte Programmierung in Java
31
2 – Die Idee objektorientierter Programmierung
also über ein Symbol (ein Icon) dargestellt und dem Anwender wird ein Menü (zum Beispiel ein Kontextmenü) bereitgestellt, das die Möglichkeiten des Objekts und seine Eigenschaften – möglicherweise abhängig von einem Zustand – zugänglich macht. Bei einer grafischen Benutzeroberfläche setzt man die Darstellung für so eine zustandsabhängige Verhaltensweise dadurch um, dass man zu einem bestimmten Zeitpunkt nicht verwendbare Fähigkeiten in einem Menü deaktiviert und grau darstellt. Die aktivierbaren Fähigkeiten und der Zugang zu den Eigenschaften werden normal dargestellt (siehe auch Abbildung 2.1).
Abb. 2.1: Objektsymbole in einer GUI samt Kontextmenü zum Bereitstellen der Fähigkeiten und Eigenschaften eines Objekts
Objektorientierte Denkweise in der Programmierung Objektorientierung lässt sich natürlich nicht nur auf Anwendungsebene in einer GUI umsetzen, sondern ebenso in der Programmierung. Hier in diesem Buch soll es ja explizit darum gehen. Unter einem Objekt stellt man sich in der EDV allgemein ein Softwaremodell vor, das ein Ding aus der realen Welt mit all seinen relevanten Eigenschaften und Verhaltensweisen repräsentieren soll und das einem Anwender über ein Symbol oder einen Bezeichner zugänglich ist, zum Beispiel das Objekt Drucker, Bildschirm oder Tastatur. Oder es handelt sich um ein Objekt aus der realen Welt, das EDV-technisch abgebildet werden soll, zum Beispiel ein Stift, ein Bankkonto, ein Bauernhof, ein Geldinstitut, ein Mensch. Auch Teile der Software selbst können ein Objekt darstel-
32
Objekte und Klassen
len, ein Browser beispielsweise oder ein Teil davon – zum Beispiel ein Frame. Es können aber auch Teile der Verzeichnisstruktur eines Rechners sein, zum Beispiel ein Ordner. Im Grunde ist im objektorientierten Denkansatz alles als Objekt zu verstehen, was sich eigenständig erfassen und ansprechen lässt.
Identifizieren Sie sich Die Verwendung von Objekten in einer GUI erfolgt wie besprochen in der Regel über Menüs – indem ein Anwender zum Beispiel mit der rechten Maustaste auf ein Objekt klickt und im Kontextmenü alle erlaubten Methoden und Eigenschaften angezeigt werden. Im Rahmen eines Quelltextes muss ein Zugriff auf Objekte natürlich anders erfolgen. Man braucht einen Namen für das Objekt oder zumindest einen Stellvertreterbegriff, der ein Objekt repräsentiert (der Identifikator). Egal ob die Identität eines Objekts durch einen grafischen oder einen textnotierten Identifikator dargestellt wird – in der Regel ist der Identifikator eine Referenz (ein Zeiger bzw. Pointer) auf das tatsächliche Objekt. Meist handelt es sich bei einer solchen Referenz technisch um eine Hauptspeicheradresse, aber es kann auch – bei persistenten1 Objekten in Datenbanken – ein Primärschlüssel der Datenbank sein.
Objektorientiert versus prozedural In der althergebrachten Programmierung (ab der dritten Generation) programmiert man prozedural. Dies bedeutet die Umsetzung eines Problems in ein Programm durch eine Folge von Anweisungen, die in einer festgelegten Reihenfolge auszuführen sind. Einzelne zusammengehörende Anweisungen werden dabei maximal in kleinere Einheiten von Befehlsschritten zusammengefasst (so genannte Unterprogramme) und die Datenebene und die Anweisungsebene können aufgetrennt werden2. Das Problem prozeduraler Programmierung ist, dass Änderungen in der Datenebene Auswirkungen auf die unterschiedlichsten Programmsegmente haben können und die Wiederverwendbarkeit von Unterprogrammen sehr eingeschränkt ist, da oft Abhängigkeiten von anderen Bestandteilen eines Programms existieren (beispielsweise von globalen Variablen). 1. Das bedeutet dauerhaft. 2. Beispielsweise mit einem Unterprogramm, das ausschließlich zur Bereitstellung von globalen Variablen verwendet wird.
Objektorientierte Programmierung in Java
33
2 – Die Idee objektorientierter Programmierung
Objektorientierte Programmierung lässt sich im Vergleich zur prozeduralen Programmierung darüber abgrenzen bzw. definieren, dass zusammengehörende Anweisungen und Daten eine zusammengehörende, abgeschlossene und eigenständige Einheit bilden – die Objekte! OOP hebt die Trennung von Datenund Anweisungsebene auf. EDV-technisch sind Objekte bestimmte Bereiche im Hauptspeicher des Rechners, in denen zusammengehörige Informationen gespeichert oder zugänglich gemacht werden.
Objektbestandteile Objekte setzen sich normalerweise aus zwei Bestandteilen zusammen: den passiven Objektdaten – das sind die Attribute bzw. Eigenschaften – und aus den aktiven Objektmethoden oder kurz Methoden bzw. Operatoren1. Wobei – abhängig von einem bestimmten Zustand eines Objekts – Methoden zeitweise zugänglich oder auch nicht zugänglich sein können. Der Zustand eines Objekts ergibt sich aus den momentanen Daten, wie sie im Objekt gespeichert sind, und den aktuellen Beziehungen des Objekts zu anderen Objekten.
UML In den meisten Modellen der OOP wird man zur Beschreibung von Klassen bzw. Objekten mit ihrem Aufbau und ihren Beziehungen gewisse Formalismen verwenden. Dabei kommt in vielen Fällen eine neutrale Sprache namens Unified Modeling Language (UML) zum Einsatz2.
Attribute Um die Zusammenhänge noch einmal aus einer anderen Sicht zu beleuchten: Man kann sagen, dass Attribute die einzelnen Dinge sind, durch die sich ein Objekt von einem anderen unterscheidet, zum Beispiel die Form, die Farbe, das Material, die Größe, das Alter, der Wert usw. Ein Attribut bezeichnet aus Sicht des Quelltextes eine benannte Eigenschaft. Ein Attribut ist ein Datenelement, das in jedem Objekt enthalten ist und in jedem dieser Objekte durch ei1. Operatoren im Sinn von UML (s.u.). 2. Auf UML gehen wir noch genauer ein. Die Darstellungsmöglichkeiten von UML werden wir in diesem Buch teilweise nutzen.
34
Objekte und Klassen
nen individuellen Wert repräsentiert ist. Im Quelltext wird jedes Attribut über einen Datentyp und einen Bezeichner repräsentiert. Die allgemeine Schreibweise in UML-Notation sieht wie folgt aus: attributName : datenTyp
Die Zugänglichkeit zu Attributen kann in der OOP über einen Zugriffsschutz geregelt werden.
Methoden Alle Objekte einer Klasse verfügen über die gleichen Methoden. Methoden stellen das dar, was Objekte tun können. Sie realisieren die Funktionalität der Objekte. Dabei gibt es neben so genannten Konstruktor- und Destruktormethoden zur Erzeugen und Beseitigung von Objekten und reinen Methoden zur Ausführung irgendwelcher Aktionen sowohl spezifische Methoden zur Abfrage von Informationen als auch Methoden zur Modifikation von Werten (so genannte Modifikatoren). Das Verhalten eines Objekts ist also festgelegt durch eine Menge von Methoden, die im Allgemeinen auf den Daten des Objekts operieren. Und auch der Zustand eines Objekts wird in der Regel durch Methodenaufrufe manipuliert. Die Manipulation des Zustands sollte auch nur über die dafür bereitgestellten Methoden (Modifikatoren) erfolgen. Denn nur darüber lässt sich sicherstellen, dass ein Objekt stets in einem konsistenten Zustand bleibt. Allerdings kann man ohne geeignete Schutzmaßnahmen den Zustand eines Objekts ebenso darüber verändern, dass Werte von Attributen direkt durch Zuweisung verändert werden (obgleich dies nicht zu empfehlen ist). Die Spezifikation von Methoden erfolgt in nahezu allen OO-Sprachen über einen Methodennamen, eine Argumentliste, einen Rückgabetyp, gegebenenfalls vertragsbasierte Vereinbarungen zum Aufruf und die konkrete Implementierung. Die Argumentliste besteht dabei aus Argumenten, für die sowohl ein Argumentname als auch ein Argumenttyp festgelegt wird. Wenn man die konkrete Implementierung nicht aufführt, redet man von der Signatur einer Methode. Dies bezeichnet also neben dem Methodennamen die Anzahl und Typen der Argumente einer Argumentliste sowie den optionalen Rückgabetyp1. 1. Und unter Umständen gewisse Informationen zum Aufruf, was wir aber bis auf Weiteres hier vernachlässigen wollen.
Objektorientierte Programmierung in Java
35
2 – Die Idee objektorientierter Programmierung
Die allgemeine Schreibweise in UML-Notation sieht wie folgt aus: methodenName(argNamel:argTYP1 argName2:argTYP2 ...) : rückgabeTyp
Auch die Zugänglichkeit zu Methoden kann in der OOP über einen Zugriffsschutz geregelt werden.
2.4.2
Information Hiding und Zugriffsschutz
Der letzte Teil der Definition für ein Objekt sagt aus, dass in der OOP Methoden und Eigenschaften immer nur über ein spezifisches Objekt zugänglich sind. Es gibt in der OOP grundsätzlich keine „freien“ Funktionalitäten und Eigenschaften. Ein Objekt ist nach außen nur durch seine offen gelegten Methoden und Attribute definiert. Es ist gekapselt, versteckt seine innere Struktur – bis auf die offen gelegten Elemente – vollständig vor anderen Objekten. Man nennt dies Information Hiding, Datenkapselung oder einfach Kapselung. Kapselung ermöglicht einen differenzierten Zugriffsschutz und kann sowohl für eine Klasse als auch für jedes Klassenelement separat festgelegt werden. Man unterscheidet in der OOP vier grundsätzliche Kategorien beim Zugriffsschutz:
쐌 public: ein öffentliches Element, das uneingeschränkt nutzbar ist. Auch eine Klasse kann als public definiert werden. In einem UML-Klassendiagramm wird ein öffentliches Element durch ein Pluszeichen (+) angezeigt. 쐌 package: ein bedingt öffentliches Klassenelement, das in der gleichen Klasse und in allen anderen Klassen des gleichen Pakets zugänglich ist. Auch eine Klasse lässt sich als package definieren. Dann kann die Klasse nur von anderen Klassen im gleichen Paket, nicht aber aus anderen Paketen, genutzt werden. In einem UML-Klassendiagramm wird ein solches Element durch eine Schlange (~) symbolisiert. 쐌 protected: ein bedingt öffentliches Klassenelement, das nur in derselben Klasse und ihren Unterklassen nutzbar ist. In einem UML-Klassendiagramm wird ein solches Element durch das Zeichen Gatterzaun (#) angezeigt. 쐌 private: ein internes Klassenelement, das ausschließlich in derselben Klasse verwendet werden kann. In einem UML-Klassendiagramm wird ein privates Element durch ein Minuszeichen (-) angezeigt.
36
Objekte und Klassen
Einige Quellen zur OOP unterscheiden übrigens nur drei Level. Dort wird die Zugänglichkeit package nicht explizit ausgeführt, obwohl der Zustand existiert. Ein ganz entscheidender Vorteil dieser Reglementierung des Zugriffsverfahrens ist, dass sich ein Objekt im Inneren, das heißt bezüglich seiner nicht offen gelegten Struktur, vollständig verändern kann. Solange es sich nur nach außen unverändert zeigt, wird das veränderte Objekt problemlos in ein System integriert, in dem es in seiner alten Form funktioniert hat. Ein weiterer Vorteil ist, dass man gezielt Zugriffe auf Interna eines Objekts steuern kann. Grundsätzlich wird die Wiederverwendbarkeit von Objekten durch möglichst restriktive Zugriffslevel extrem erhöht, was einen der Hauptvorteile der OOP darstellt.
2.4.3
OO-Philosophie als Abstraktion
Die gesamte objektorientierte Philosophie entspricht viel mehr der realen Natur als der prozedurale Denkansatz, der von der Struktur des Computers definiert wird. Ein Objekt ist im Sinne der objektorientierten Philosophie eine Abstraktion eines in sich geschlossenen Elements der realen Welt. Dabei spricht man von Abstraktion, weil zur Lösung eines Problems normalerweise weder sämtliche Aspekte eines realen Elements benötigt werden noch überhaupt darstellbar sind1. Irgendwo muss immer abstrahiert werden. Ein Mensch bedient sich eines Objekts, um eine Aufgabe zu erledigen. Man weiß in der Regel sogar nicht genau, wie das Objekt im Inneren funktioniert, aber man kann es bedienen (kennt also die Methoden, um es zu verwenden) und kennt die Eigenschaften des Objekts (seine Attribute) und weiß, wann was zur Verfügung steht (sein Zustand). Die Reihe von Beispielen lässt sich beliebig ausdehnen, wir wollen es beim Auto, der Waschmaschine oder – natürlich – der Kaffeemaschine (es geht ja in dem Buch um Java) belassen.
Ich denke, also bin ich Aus programmiertechnischer Sicht ist es so, dass nicht nur der Programmierer weiß, was ein Objekt leistet. Auch das Objekt selbst weiß es und kann es nach außen dokumentieren. Es ist sich quasi seiner Existenz bewusst. Das wird in 1. Oder ist Ihnen der Aufbau der Moleküle eines Kugelschreibers bekannt? Oder etwa gar nicht von Interesse ;-)?
Objektorientierte Programmierung in Java
37
2 – Die Idee objektorientierter Programmierung
den Java-Tools beispielsweise sehr umfangreich genutzt und äußert sich darin, dass der Compiler beim Übersetzen bereits vieles von dem abfangen kann, was in Java nicht erlaubt ist. Aber sogar schon vor der Kompilierung lässt sich diese Information nutzen. In geeigneten Entwicklungsumgebungen kann Ihnen ein Editor bereits Hilfe anbieten, indem er Ihnen zu einem Objekt alles anzeigt, was das Objekt leisten kann (siehe Abbildung 2.2). Was da dann nicht auftaucht, kann auch nicht von einem Objekt gefordert werden.
Abb. 2.2: Ein Objekt gibt einer IDE gegenüber Auskunft, was es an Eigenschaften und Methoden bereitstellt
In der OOP gibt es also eine Hilfe in zwei Richtungen. Der Programmierer sieht in einer geeigneten IDE sämtliche Eigenschaften und verfügbaren Methoden eines Objekts und wird auf der anderen Seite sofort darauf hingewiesen, wenn er etwas vom Objekt anfordert, was dieses nicht bietet (zum Beispiel bei einem Schreibfehler in einer Methode oder Eigenschaft).
2.4.4
Klassen und Konstruktoren
Wenn nun Objekte die Basis der OOP sind und man in streng objektorientierten Sprachen wie Java ohne Objekte nichts machen kann – wie entstehen Objekte und was muss man als Programmierer konkret tun? Die Lösung heißt Klassen (auch Objekttyp oder abstrakter Datentyp genannt) und darin enthaltene Konstruktoren.
38
Objekte und Klassen
Klassen Eine Klasse kann man sich einmal als eine Gruppierung von ähnlichen Objekten vorstellen, die deren Klassifizierung ermöglicht. Eine Klasse ist also die Definition der gemeinsamen Attribute und Methoden sowie der Semantik für eine Menge von gleichartigen Objekten. Alle erzeugten Objekte einer Klasse werden dieser Definition entsprechen. Eigenschaften und Funktionalität der Objekte werden also in der Gruppierung gesammelt und für eine spätere Erzeugung von realen Objekten verwendet. Mit anderen Worten – Klassen sind so etwas wie Baupläne oder Rezepte, um mit deren Anleitung ein konkretes Objekt zu erzeugen. Ein aus einer bestimmten Klasse erzeugtes Objekt nennt man deren Instanz1. In einer Klassenspezifikation können Instanzelemente und Klassenelemente2 beschrieben werden. Das sind in beiden Fällen die Attribute und die Methoden, die ein Objekt besitzen soll und die wie oben beschrieben notiert werden. Aber die beiden Typen unterscheiden sich im Zeitpunkt ihrer Existenz und im möglichen Zugang.
Instanzelemente Instanzattribute und Instanzmethoden sind die Elemente, die erst in einer konkret erzeugten Instanz existieren und erst dort verwendet werden können. Sie gehören explizit zu einer konkreten Instanz. Andere Instanzen bekommen nicht mit, wann und wie eine Instanz auf ihrem Instanzelement operiert.
Klassenelemente Klassenattribute (auch statische Attribute genannt) sind Attribute, die zu einer Klasse selbst und nicht zu einem spezifischen Objekt gehören. Alle Objekte einer Klasse haben gemeinsam Zugriff auf statische Attribute, die unabhängig davon existieren, ob es von der Klasse kein, ein oder mehrere Objekte gibt. 1. Sehr oft werden die Begriffe Objekt und Instanz synonym verwendet. Allerdings beschreiben die Begriffe zwar das gleiche Objekt, aber unterschiedliche Sichtweisen darauf. Nehmen wir als Beispiel George Bush. Als Objekt betrachtet, stellt er den Präsidenten der USA dar. Als Instanz von Bush Senior ist er dessen Sohn. So oder so bleibt es das gleiche Objekt, aber die Sichtweise unterscheidet sich. Dennoch – in vielen Quellen werden beide Ausdrücke synonym verwendet und wir wollen sie auch nicht streng unterscheiden. 2. Klassenelemente werden auch als Metadaten bezeichnet.
Objektorientierte Programmierung in Java
39
2 – Die Idee objektorientierter Programmierung
Vollkommen analog sind Klassenmethoden (auch statische Methoden genannt) solche Methoden, die unabhängig von einem Objekt der Klasse ausgeführt werden können. Statische Attribute und Methoden ähneln in gewisser Weise globalen Variablen und globalen Funktionen in prozeduralen Programmiersprachen. Statische Klassenelemente sind jedoch im Unterschied zu diesen dem Namensraum der Klasse zugeordnet. Sie sind also auf die Klasse und ihre Instanzen beschränkt und existieren nicht in anderen Klassen. Dafür existieren Klassenelemente grundsätzlich bereits bei Programmstart. Das bedeutet auch dann, wenn noch kein Objekt konstruiert wurde.
2.5 Methodologien der objektorientierten Softwareentwicklung Die objektorientierte Softwareentwicklung befasst sich im Allgemeinen mit der Frage, wie die Entwicklung von Softwaresystemen möglichst effektiv realisiert werden kann. Die eigentliche Entwicklung und die eingesetzten Techniken werden bei der professionellen Softwareentwicklung von einem theoretischen Modell begleitet – der so genannten Methodologie. Diese umfasst verschiedene Modellierungssprachen und Vorgehensmodelle. Eine Modellierungssprache dient dabei zur abstrakten Darstellung der Architektur eines Softwaresystems und ein Vorgehensmodell legt fest, wie der Prozess der Softwareentwicklung gegliedert wird. Der Vorteil objektorientierter Methodologien besteht darin, dass diese die konzeptuellen Fragen an den Anfang des Entwicklungsprozesses eines Projekts verlagern. Die konkrete Implementierung (Kodierung) von Software findet erst in einer Phase statt, in der man sich bereits intensiv mit einem zu lösenden Problem beschäftigt hat. Entwurfsfehler sind vor Beginn der Implementierung exorbitant leichter zu beseitigen als nach oder während der tatsächlichen Kodierung.
Ein Modell als vereinfachte Darstellung Allgemein ist in der Methodologie ein Modell eines zu lösenden Problems eine vereinfachte Darstellung des Sachverhalts. Damit soll eine Untersuchung erleichtert oder gar erst möglich gemacht werden. Die Modellierung ist eine Beschreibung, die vollkommen abstrahiert von der späteren konkreten Implementierung zu sehen ist und vor allem programmiersprachenunabhängig ist.
40
Methodologien der objektorientierten Softwareentwicklung
OOA und OOD Jede Methodologie basiert darauf, zu Beginn ein Modell eines Anwendungsszenarios zu entwerfen, aus dem in einem oder mehreren weiteren Schritten die konkrete Implementierung erzeugt wird. In der objektorientierten Programmierung redet man allgemein von der objektorientierten Analyse (OOA), dem objektorientierten Design (OOD) und der konkreten Implementierung über die OOP. Diese drei Phasen stellen in jedem Vorgehensmodell für die objektorientierte1 Softwareentwicklung die drei Hauptphasen dar.
2.5.1
Die Analyse
In der Analysephase wird ein Modell eines Anwendungsszenarios entworfen, das noch sehr abstrakt ist. Das fertige Modell legt fest, was ein Softwaresystem später tun muss. Es legt jedoch nicht fest, wie das System dies tut. Insbesondere bestimmt das Modell noch keine Implementierungsdetails.
2.5.2
Das Design
In der Designphase erfolgt auf Basis des Analysemodells der Entwurf des Systems. Dabei wird die grundlegende Architektur des Systems festgelegt (zum Beispiel die konkrete Programmiersprache) und in der OO-Softwareentwicklung vor allem ein Klassenentwurf vorgenommen. Dazu werden die aus der Analyse stammenden Klassen bereits um implementierungsspezifische Datenstrukturen und Algorithmen erweitert.
2.5.3
Die Implementierung
Die OOP2 kommt zum Einsatz, wenn das System auf Grund der im OOD entwickelten Klassen und ihrer Beziehungen implementiert wird. Erst hier wird konkret nach den Vorgaben aus dem Design kodiert.
1. Auch nichtobjektorientierte Softwareentwicklung verwendet eine Analyse-, Design- und Implementierungsphase. 2. Worum es in diesem Buch geht.
Objektorientierte Programmierung in Java
41
2 – Die Idee objektorientierter Programmierung
2.5.4
Vorgehensmodelle bei der Softwareentwicklung
Bei der professionellen Softwareentwicklung unterscheidet man mehrere Vorgehensmodelle, die sich mehr oder weniger für die objektorientierte Programmierung eignen.
Das traditionelle Wasserfallmodell Das Wasserfallmodell stammt aus der traditionellen Softwareentwicklung und beschreibt eine lineare Abfolge der einzelnen Phasen der Softwareentwicklung. Die – möglicherweise unterschiedlich fein granulierten – Phasen reichen von einer Analyse über das Design, die konkrete Kodierung bis hin zur Einführung beim Kunden. Eine Rückmeldung an eine beendete Phase erfolgt in der Regel nicht. Wenn eine Phase beendet ist (etwa das Design), wird in diese Phase nicht mehr eingetreten. Maximal ist ein (eingeschränktes) Feedback zur unmittelbaren Vorgängerphase vorgesehen. Das Wasserfallmodell eignet sich so gut wie gar nicht für die objektorientierte Softwareentwicklung.
Das Mauermodell Das Mauermodell ist dem Wasserfallmodell verwandt und zeichnet sich ebenfalls durch eine lineare, starre und klar festgelegte Reihenfolge sowie streng getrennte Phasen aus. Das Modell definiert fünf Hauptphasen: die Analyse, den Entwurf, die Implementierung, den Test und die Wartung bzw. den Betrieb des Softwaresystems. In jeder Hauptphase kommen unterschiedliche Methoden, Techniken und Notationen zum Einsatz, die sich stark unterscheiden können. Bei der objektorientierten Programmierung wird versucht, das Mauermodell durch eine gemeinsame Spezifikation über alle Phasen hinweg einigermaßen zu verbinden. Dennoch ist das Mauermodell in der OOP nur begrenzt im Einsatz. Stattdessen wird bei vielen OO-Projekten mittlerweile auf das nachfolgende Vorgehensmodell gesetzt.
Das Spiralenmodell Das Spiralenmodell bezeichnet eine iterative, inkrementelle Softwareentwicklung. Dieses Modell erweist sich mehr und mehr als das ideale Vorgehensmodell für die objektorientierte Softwareentwicklung. Dabei werden die verschiedenen Phasen der Softwareentwicklung von der Analyse über das De-
42
Methodologien der objektorientierten Softwareentwicklung
sign bis hin zur Implementierung und möglicherweise sogar einem Testbetrieb „in the wild“ immer wieder durchlaufen (iterativ). In jedem Durchgang wird ein Teil der Anforderungen bearbeitet, konkretisiert und/oder verbessert. Das betrifft sowohl konzeptionelle als auch technische Schritte. Das Softwaresystem wird mit jedem Durchlauf (inkrementell) optimiert und bis zu einem Stand vorangetrieben, an dem es einem Kunden übergeben werden kann.
2.5.5
UML und Diagrammdarstellungen in der OOP
Wir hatten schon darauf hingewiesen, dass in den meisten Modellen der OOP zur grafischen Darstellung von Klassen mit ihrem Aufbau und ihren Beziehungen Diagramme auf Basis von UML verwendet werden. Aber auch für die Darstellungen von Objekten und ihren Beziehungen, für den Ablauf von Programmen und einiges mehr kommen Diagramme zum Einsatz. Solche Hilfsmittel werden vor allem in Analyse- und Designphasen der Softwareentwicklung als flankierende oder gar unabdingbare Maßnahmen Verwendung finden. UML bezeichnet eine vereinheitlichte Modellierungssprache, mit der allgemein Strukturen und Abläufe in objektorientierten Softwaresystemen darzustellen sind. Seit 1997 gibt es UML. Derzeit ist die Version UML 2.0 aktuell. UML ist eine Weiterentwicklung verschiedener älterer, objektorientierter Methodologien wie Object Modeling Technique, die Booch-Methode oder Object-Oriented Software Engineering. Die Sprache definiert eigene Bezeichner und Symbole für die meisten Begriffe in der OO sowie deren mögliche Beziehungen und Abläufe. In geeigneten Tools kann man mit UML OO-Modelle „zeichnen“, die sogar per Mausklick direkt in Grundgerüste (Skeletons oder Stubs) einer konkreten OO-Sprache überführt werden können (siehe Abbildung 2.3). UML 2.0 unterstützt 13 Diagrammtypen, die in Strukturdiagramme und Verhaltensdiagramme unterteilt werden. Als Strukturdiagramme stehen Klassendiagramme, Objektdiagramme, Komponentendiagramme, Kompositionsstrukturdiagramme, Verteilungsdiagramme und Paketdiagramme zur Verfügung. Als Verhaltensdiagramme gibt es Anwendungsfalldiagramme, Zustandsdiagramme, Aktivitätsdiagramme, Sequenzdiagramme, Interaktionsübersichtsdiagramme, Kommunikationsdiagramme sowie Zeitverlaufsdiagramme.
Objektorientierte Programmierung in Java
43
2 – Die Idee objektorientierter Programmierung
Abb. 2.3: Generierung von Java-Code aus einem UML-Diagramm mit einem geeigneten Tool
Klassendiagramme zur Sicht auf statische Aspekte Über UML lassen sich also unter anderem so genannte Klassendiagramme oder Objektdiagramme darstellen, die die Designphase unterstützen. Ein Klassendiagramm repräsentiert im Wesentlichen Klassen selbst sowie ihre Klassenelemente und ihre Klassenbeziehungen. Ein Klassendiagramm liefert eine Sicht auf ausgewählte, statische Aspekte des modellierten Softwaresystems, die uns bei der Einarbeitung in die OOP viel helfen kann. Als Überschrift einer Klassendarstellung wird deren Name1 angegeben, darunter folgen die Eigenschaften und Methoden der Klasse samt Sichtbarkeit und Typ. Statische Elemente werden in einem Klassendiagramm unterstrichen dargestellt und bei Attributen kann über ein Gleichheitszeichen ein zugewiesener Startwert angezeigt werden. Diese Sichtweise auf Klassen werden wir im Laufe des Buchs kontinuierlich einsetzen.
1. Bei einer Schnittstelle mit einem vorangestellten interface.
44
Methodologien der objektorientierten Softwareentwicklung
Abb. 2.4: Ein einfaches Klassendiagramm für eine Klasse nach UMLNotation mit zwei Attributen und einer Methode
Ein Objektdiagramm hingegen repräsentiert eine Menge ausgewählter Objekte und ihre Beziehungen zu anderen Objekten zu einem bestimmten Zeitpunkt. Objektdiagramme stellen also konkrete, kontextabhängige Situationen dynamisch dar. Wir werden Objektdiagramme sowie die anderen Diagrammformen von UML im Laufe des Buchs so gut wie gar nicht weiterverwenden. Beachten Sie auch, dass wir in dem Buch nicht streng zwischen Begriffen aus der UML und der OOP mit Java trennen, wenn die Begriffe das Gleiche bezeichnen.
2.5.6
Konstruktoren und andere Formen der Objekterzeugung
Wie lässt sich eine Klasse jetzt nutzen? Man kann in gewissen Situationen eine Klasse direkt verwenden oder man muss daraus ein Objekt erzeugen oder zumindest sonst irgendwie ein Objekt ins Spiel bringen. In vielen objektorientierten Sprachen erzeugt man in der OOP Objekte mittels so genannter Konstruktoren1, von denen es in jeder (!) Klasse per Definition mindestens einen gibt. Bei Konstruktoren (engl. Constructors) handelt es sich um spezielle Methoden (deshalb wird auch oft von Konstruktormethoden gesprochen), deren einzige Aufgabe die Erstellung einer Instanz einer Klasse ist. Dabei wird ein Objekt erzeugt, bestimmte Eigenschaften der Instanz werden festgelegt, bei Bedarf werden notwendige Aufgaben ausgeführt und vor allem wird Speicher für die Instanz allokiert2. Der Konstruktor liefert als Rückgabewert eine Refe1. Beachten Sie, dass es auch OOP-Sprachen gibt, die ohne Konstruktoren auskommen. Da wir aber Java als Ziel haben, werden wir uns hier auf diesen etwas spezialisierten Ansatz der Konstruktoren zurückziehen. C++, C# oder Delphi verwenden auch Konstruktoren. 2. In einigen OO-Sprachen (aber nicht Java) heißen Konstruktormethoden explizit create(), was die Funktion deutlich macht. Der Bezeichner create wird auch in UML-Klassendiagrammen zum Anzeigen von Konstruktoraufrufen verwendet.
Objektorientierte Programmierung in Java
45
2 – Die Idee objektorientierter Programmierung
renz auf eine Instanz zurück, die einem Identifikator für das Objekt zugewiesen werden kann. Darüber erfolgt dann später der Zugriff auf das Objekt.
2.5.7
Destruktoren
Das Gegenstück zu Konstruktoren sind Destruktoren. Diese vernichten ein nicht mehr benötigtes Objekt, führen Aufräumarbeiten aus und geben Speicher frei, der von einem Objekt belegt wurde1. Destruktoren greifen bei einem manuellen Aufruf über den Identifikator eines Objekts darauf zu oder werden – wie in Java – automatisch aufgerufen.
2.6 Message in a bottle – Botschaften Besonders wichtig ist in Zusammenhang mit der Verwendung von Objekten der Begriff der Botschaften. Damit Objekte im Rahmen eines Quelltextes verwendet werden können, tauschen sie so genannte Botschaften (oder Nachrichten bzw. Messages) aus. In der strengen OOP werden ausschließlich Botschaften für die Kommunikation zwischen Objekten verwendet. Andere Kommunikationswege gibt es nicht2. Das Objekt, von dem man etwas will, erhält eine Aufforderung, eine bestimmte Methode auszuführen oder den Wert einer Eigenschaft zurückzugeben oder zu setzen. Das Zielobjekt versteht (hoffentlich) diese Aufforderung und reagiert entsprechend. Die genaue formale Schreibweise solcher Botschaften ist in den meisten OO-Programmiersprachen nach dem folgenden beispielhaften Schema aufgebaut: Empfängerobjekt [Methode oder Eigenschaft]
In den meisten OO-Sprachen trennt dabei ein Punkt die Bestandteile der Botschaft. Deshalb wird von der Punktnotation bzw. DOT-Notation gesprochen.
1. In einigen OO-Sprachen heißen Destruktoren explizit destroy(), was die Funktion deutlich macht. Der Bezeichner destroy wird auch in UML-Klassendiagrammen zum Anzeigen von Destruktoraufrufen verwendet. 2. Dies schließt beispielsweise die Existenz globaler Variablen aus. Hybride OO-Sprachen verfügen jedoch über globale Variablen und können damit die Verwendung von Botschaften umgehen (und damit die strenge OO-Philosophie aushebeln).
46
Assoziationen
Eine Botschaft, dass ein Objekt eine bestimmte Methode ausführen soll, sieht also meist wie folgt aus: Empfänger.methodenName(Argument)
Das Argument stellt in dem Botschaftsausdruck einen Übergabeparameter für die Methode dar. Eine ähnliche Form wird auch für die Verwendung von Objektattributen gewählt. In der Regel sieht das dann so aus: Empfänger.attributName
Zur Spezifikation und Dokumentation des Nachrichtenaustauschs zwischen Objekten verfügt UML über so genannte Sequenz- und Kollaborationsdiagramme. Diese Diagramme erlauben es, den Nachrichtenaustausch zwischen einer ausgewählten Menge von Objekten exemplarisch in zeitlicher Reihenfolge festzuhalten.
2.7 Assoziationen Eine Assoziation oder Nutzungsbeziehung stellt eine Beziehung zwischen zwei oder mehr Typen dar. Eine Assoziation beschreibt im häufigsten Fall eine Verbindung zwischen zwei Klassen, also wie Instanzen dieser beiden Klassen bzw. die beiden beteiligten Klassen selbst in Verbindung zueinander stehen (eine so genannte binäre Assoziation). Gelegentlich beschreibt eine Assoziation auch Beziehungen zwischen einer Klasse und einer Schnittstelle. In seltenen Fällen wird die Beziehung zwischen mehr als zwei Typen beschrieben. Assoziationen bezeichnen also verschiedene Arten von Objektverbindungen. Eine Assoziation zwischen zwei Klassen legt ein strukturelles Beziehungsmuster zwischen Objekten dieser Klassen fest. Zur Laufzeit wird zwischen zwei konkreten Objekten eine Verbindung aufgebaut, indem ein Objekt das andere referenziert. Assoziationen können in UML natürlich beschrieben werden. Die Assoziation zwischen Klassen kann einen Namen, den Assoziationsnamen, haben. Die Bedeutung der jeweils beteiligten Objekte wird durch Rollennamen festgelegt. Die Menge der beteiligten Objekte kann durch Mengenangaben spezifiziert werden. Dabei steht beispielsweise „1“ für genau ein Objekt oder „*“ für beliebig viele beteiligte Objekte. Assoziationen werden in
Objektorientierte Programmierung in Java
47
2 – Die Idee objektorientierter Programmierung
Klassendiagrammen durch Verbindungslinien zwischen den Klassen dargestellt und mit speziellen Symbolen und Zeichen genauer spezifiziert.
Abb. 2.5: Ein UML-Klassendiagramm mit einer einfachen Assoziation zwischen einer Klasse „Konto“ und einer Klasse „Person“
Benutzungsbeziehung Dabei spricht man von einer Benutzungsbeziehung (use relation), wenn ein Objekt ein anderes Objekt bzw. dessen Methoden oder Attribute benutzt. Dafür wird in der Regel der Begriff kaufen oder uses verwendet. Ein Objekt kauft Funktionalität von einem anderen Objekt, wenn es dieses verwendet.
Komposition und Aggregation Eine Komposition (composite aggregation) oder Aggregation (shared aggregation) beschreibt die Beziehung zwischen einem Ganzen und seinen Teilen. Allgemein unterscheidet man beide Begriffe oft nicht. Die einzigen eindeutigen Unterscheidungsmerkmale sind die Multiplizität und bei der shared aggregation existieren die Teile unabhängig vom Aggregat, bei der Komposition nicht. Teile, die über eine Komposition mit einem Ganzen verbunden sind, dürfen jeweils in höchstens einem Ganzen vorkommen. Falls es sich bei der Assoziation um eine Aggregation handelt, kann ein Teil auch mehrfach in einem Ganzen vorkommen (etwa ein Objekt „Konto“, das zu einem verwaltenden Objekt „Kontogruppe“ gehört). Die andere Form einer Beziehung ist die Vererbung. In diesem Fall erbt ein Objekt von einem übergeordneten Objekt Funktionalität.
2.8 Vererbung In der OOP werden ähnliche Objekte bekanntlich zu Gruppierungen (Klassen) zusammengefasst, was eine leichtere Klassifizierung der Objekte ermöglicht. Die Eigenschaften und Methoden der Objekte werden also in den Gruppierun-
48
Vererbung
gen gesammelt und für eine spätere Erzeugung von realen Objekten verwendet. Zentrale Bedeutung hat dabei die hierarchische Struktur der Gruppierungen – von allgemein bis fein. Beispiel: Ein Objekt vom Typ Schaf gehört zur Klasse Säugetier. Diese Klasse wiederum gehört zu einer höheren Klasse Tier und diese wiederum zur Klasse Lebewesen (siehe auch Abbildung 2.6).
Superklasse und Subklasse Gemeinsame Erscheinungsbilder sollten also in der objektorientierten Philosophie in einer möglichst hohen Klasse zusammengefasst werden. Erst wenn Unterscheidungen möglich bzw. notwendig sind, die nicht für alle Mitglieder einer Klasse gelten, werden Untergruppierungen – untergeordnete Klassen – gebildet. Vererbung bezeichnet nun eine Verbindung zwischen einer Klasse und einer oder mehreren anderen Klassen, in der die abgeleitete Klasse das Verhalten und den Aufbau der Oberklassen übernimmt, gegebenenfalls neues Verhalten und einen erweiterten Aufbau besitzt und übernommenes Verhalten modifiziert. Die vererbende Klasse nennt man Basisklasse, Oberklasse, Elternklasse oder Superklasse. Die Klasse, die erbt, bezeichnet man als abgeleitete Klasse, Unterklasse, Kindklasse oder Subklasse.
Subklassen als Spezialisierung einer Superklasse Eine Subklasse ist immer ein Spezialfall ihrer Superklasse und sollte in jeder Hinsicht mit ihr kompatibel sein. Ein Schaf ist immer auch ein Tier, also ein Spezialfall eines Tiers. Eine Vererbungsbeziehung wird auch als „Ist-ein“-Relation oder „Art-von“- bzw. „Kind-of“-Relation bezeichnet.
Klassenbaum Die ineinander geschachtelten Klassen bilden einen so genannten Klassenbaum. Dieser kann im Prinzip beliebig tief werden – eben so tief, wie es notwendig ist, um eine Problemstellung detailliert zu beschreiben. Vererbung ist über eine beliebige Anzahl von Ebenen im Klassenbaum hinweg möglich. Die oberste Klasse des Baums heißt Wurzelklasse (root class). Die Klassen am Ende eines Vererbungsbaums heißen Blattklassen (leaf classes). Klassen, von denen aus keine Unterklassen mehr gebildet werden dürfen, heißen finale Klassen (final classes).
Objektorientierte Programmierung in Java
49
2 – Die Idee objektorientierter Programmierung
Abb. 2.6: Ein einfacher Klassenhierarchiebaum, in dem eine Subklasse jeweils eine Eigenschaft oder Methode der direkten Superklasse hinzufügt
2.8.1
Generalisierung und Spezialisierung
Vererbung bezeichnet konkret den Mechanismus, Attribute und Methoden mit Hilfe einer Klassenbeziehung wiederzuverwenden. Bei der Beschreibung der Beziehungen in einem Klassenbaum redet man von Generalisierung und Spezialisierung. Diese stellen gegensätzliche Sichtweisen auf die gleiche Beziehung dar – aus der Sicht der Superklasse bzw. aus der Sicht der Subklassen. Generalisierung leitet sich als Begriff aus der Tatsache ab, dass die Superklasse die Unterklassen verallgemeinert und alle Gemeinsamkeiten ihrer Subklassen darstellt1. Spezialisierung leitet sich daher ab, dass die Subklassen die Superklasse verfeinern (sie spezialisieren), indem sie neue Klassenelemente 1. Ein Tier ist eine Verallgemeinerung eines Schafs.
50
Vererbung
hinzufügen bzw. Methoden anders implementieren1. Durch die Spezialisierung ist eine Instanz einer Klasse zugleich Instanz aller ihrer Superklassen und jede öffentliche Methode, die für eine der Superklassen definiert wurde, kann auch auf eine Instanz der Subklassen angewendet werden.
2.8.2
Technische Umsetzung einer Vererbung
Bei der Vererbung übernimmt die Subklasse alle für die Vererbung freigegebenen Attribute und Methoden der Superklasse. Die Beziehung der ursprünglichen Klasse, der Superklasse zur abgeleiteten, der Subklasse, ist immer streng hierarchisch. Jede Subklasse erbt alle für die Vererbung freigegebenen Eigenschaften und Methoden ihrer Superklasse. Sie beinhaltet also immer mindestens die gleichen Eigenschaften und Methoden wie die Superklasse, wobei durch geeignete Zugriffsregeln in der Superklasse Strukturen gegenüber den Subklassen verborgen werden können. Zusätzlich kann (und sollte in der Regel) die Subklasse neue Attribute festlegen oder zumindest die Werte von Attributen verändern und/oder neue Methoden hinzufügen bzw. bestehende Methoden modifizieren. Abgeleitete Klassen übernehmen die offen gelegten Eigenschaften und Methoden aller übergeordneten Klassen, wobei Übernehmen nicht heißt, dass eine Subklasse die Befehle und Eigenschaften der Superklasse in ihre eigene Deklaration kopiert. Stattdessen gibt es nur eine formale Verknüpfung zwischen den Klassen. Dies wird in Java mit einer Art von Zeiger realisiert, obwohl Java aus Sicht des Programmierers nicht explizit mit Zeigern arbeitet. Mit anderen Worten: Die abgeleitete Klasse verwendet bei Bedarf die Methoden oder Eigenschaften der Superklasse. Den Mechanismus kann man sich analog dem Verwenden von Bibliotheken und den dort implementierten Funktionalitäten vorstellen. Die Methoden- bzw. Eigenschaftenauswahl in einer Klassenhierarchie muss natürlich geregelt sein. Sie erfolgt nach einer einfachen Regel. Ist der Nachrichtenselektor (der Methoden- oder Attributname einer Botschaft) in der Klasse des Empfängers nicht vorhanden, so wird die gewünschte Methode oder Eigenschaft in der nächsthöheren Superklasse des Nachrichtenselektors gesucht. Ist sie dort nicht vorhanden, erfolgt die Suche in der nächsthöheren Klasse, bis die Wurzelklasse des Klassenbaums erreicht 1. Ein Schaf zeichnen aus objektorientierter Sicht mehr Eigenschaften und Methoden aus als ein allgemeines Tier. Ein Schaf ist ein spezielles Tier.
Objektorientierte Programmierung in Java
51
2 – Die Idee objektorientierter Programmierung
ist1. Die Ausführung der Methode bzw. der Zugriff auf die Eigenschaft erfolgt also in der ersten Klasse, in der sie gefunden wird (von der aktuellen Klasse in der Hierarchie aufwärts gesehen). Gibt es im Klassenbaum keine Methode bzw. Eigenschaft des spezifizierten Namens, so kommt es zu einer Fehlermeldung. Klassen, die sich auf derselben Ebene wie die aktuelle Klasse oder in anderen Zweigen des Klassenbaums befinden, werden nicht durchsucht.
2.8.3
Mehrfachvererbung versus Einfachvererbung
In der Theorie der Objektorientierung gibt es Einfachvererbung sowie Mehrfachvererbung. In der Einfachvererbung (englisch Single Inheritance) gilt für die Klassenhierarchie in einer baumartigen Struktur die Voraussetzung, dass eine Subklasse immer nur genau eine Superklasse hat und eine Superklasse eine beliebige Anzahl an Subklassen haben kann. Wenn jedoch die Möglichkeit besteht, eine einzige Klasse direkt mit beliebig vielen Superklassen durch Vererbung zu verknüpfen, nennt man dies Mehrfachvererbung (englisch Multiple Inheritance). Objekte der Subklasse erben direkt Eigenschaften aus verschiedenen Superklassen.
Pro und Contra Mehrfachvererbung Es gibt unbestritten einige gute Gründe für die Mehrfachvererbung. Der entscheidende Vorteil der mehrfachen Vererbung liegt in der Möglichkeit, die Probleme der realen Welt einfacher beschreiben zu können, denn auch dort hat ein Objekt Eigenschaften und Fähigkeiten aus verschiedenen übergeordneten logischen Bereichen. Ein Auto kann in der Mehrfachvererbung beispielsweise direkt von verschiedenen Superklassen erben. Die Klasse Auto erbt zum Beispiel direkt von den Klassen Motor, Karosserie, Fahrgestell, Bremsen und Innenraum2. Mit Mehrfachvererbung lässt sich das Beziehungsgeflecht des realen Objekts durch die Sammlung der Superklassen (relativ) vollständig und einfach beschreiben. Ein weiterer Vorteil ist ebenso, dass man leicht Dinge ergänzen kann, die man bei der ersten Realisierung einer Klasse vergessen hat. Wenn bestimmte Eigenschaften und Methoden in einer Subklasse vergessen wurden, 1. Dies ist bei Java in jedem Fall die Klasse java.lang.Object. 2. Bitte nicht das mangelhafte Design kritisieren. Es soll nur ein Beispiel für potenzielle Probleme sein.
52
Vertragsbasierte Programmierung
nimmt man einfach eine weitere Superklasse hinzu, die die fehlenden Elemente vererben kann. Dieser Vorteil einer relativ vollständigen und einfachen Abbildung der Natur steht dahingegen in keinem Verhältnis zu den damit eingekauften Nachteilen. Die schlimmsten Nachteile sind sicher die kaum nachvollziehbaren Beziehungsgeflechte in komplexeren Programmen. C/C++-Programmierer (vor allem diejenigen, die ein Programm übernehmen mussten) wissen davon ein Lied zu singen. Wartbarkeit wird bei exzessivem Einsatz der Mehrfachvererbung zum Fremdwort. Java arbeitet deshalb ohne Mehrfachvererbung. Dafür bietet Java über so genannte abstrakte Klassen und Schnittstellen ein Verfahren, das vieles kompensiert, was durch den Verzicht auf die Mehrfachvererbung verloren ging. Diese Techniken werden im späteren Verlauf des Buchs noch ausführlich erläutert.
2.9 Vertragsbasierte Programmierung Über weite Strecken der OOP kommen so genannte Verträge bzw. Kontrakte zum Einsatz, entweder in eigenen Sprachkonstrukten oder durch geeignete Programmierung. Vertragsbasierte Programmierung oder Kontraktprogrammierung legt zwischen aufrufenden und aufgerufenen Objekten fest, welche Bedingungen vor dem Aufruf und nach dem Aufruf gelten müssen. Bedingungen beziehen sich auf den Zustand des aufgerufenen Objekts, die übergebenen Parameter und die Ergebnisse. Insbesondere legt ein Kontrakt fest, welche Eingangs- und Ausgangsbedingungen bei einem Methodenaufruf sowie welche invarianten Eigenschaften im aufgerufenen Objekt gegeben sein müssen. Bei einem Kontrakt werden einzelne Bestandteile unterschieden:
쐌 Preconditions legen die Parameter und den Anfangszustand des aufgerufenen Objekts fest. 쐌 Postconditions legen die Ergebnisse und den Zustand des aufgerufenen Objekts nach Ausführung des Aufrufs fest. 쐌 Invariants überprüfen die Konsistenz des Zustands von dem aufgerufenen Objekt. Ein Kontrakt wird immer zwischen dem Aufruferobjekt (dem Client) und dem aufgerufenen Objekt (dem Supplier) geschlossen. Der Aufrufer kümmert sich um die Einhaltung der Preconditions. Dies bedeutet zum Beispiel sinnvolle
Objektorientierte Programmierung in Java
53
2 – Die Idee objektorientierter Programmierung
Werte für die Argumentwerte bei einem Methodenaufruf. Der Supplier kümmert sich darum, dass bei Beendigung eines Methodenaufrufs die Postconditions erfüllt sind. Dies bedeutet etwa, dass ein erwarteter Rückgabewert auch geliefert wird1. Dabei befindet sich das aufgerufene Objekt vor und nach dem Aufruf der Methode in einem gültigen Zustand.
2.10 Zusammenfassung Sie kennen nun elementare Ideen der OOP. OOP basiert auf der Abbildung eines Denkmodells, das Menschen in der realen Welt quasi natürlich verwenden. Objekte werden als abgeschlossene Einheiten verstanden, die eine Reihe von Eigenschaften und Funktionalitäten (Methoden) bereitstellen, wobei der Zustand eines Objekts von Bedeutung sein kann. Sowohl zur Klassifizierung mehrerer Objekte als auch zur Erzeugung von Objekten dienen Klassen, aus denen mit Hilfe von Konstruktoren Objekte als Instanzen erzeugt werden. Die konkrete Verwendung von Objekten erfolgt mit Hilfe der Punktnotation, bei der der Name eines Objekts oder ein Stellvertreterausdruck vorangestellt und – durch einen Punkt getrennt – die gewünschte Methode oder die gewünschte Eigenschaft nachgestellt wird. Klassen und Objekte können andere Klassen und Objekte verwenden, wobei entweder Funktionalität hinzugekauft oder vererbt wird. Bei der konkreten Softwareentwicklung kommen verschiedene Methodologien zum Einsatz, die unterschiedliche Phasen und Vorgehensweisen bei der Erstellung eines Systems begleiten oder gar erst ermöglichen.
1. Für die schon etwas Fortgeschrittenen: Das umfasst zum Beispiel auch den Fall, dass eine Ausnahme nur dann von einer Methode ausgeworfen werden kann, wenn dies in der Methodensignatur dokumentiert wird.
54
3
Objektorientierte Programmierung mit Java
In diesem Kapitel werden die fundamentalen Konzepte von Java als objektorientierte Programmiersprache gezeigt und dabei die theoretischen Ausführungen des letzten Kapitels in konkreten Anwendungen auf Basis von Java demonstriert. Zudem erfahren Sie anhand praktischer Java-Beispiele weitere Details zu den Konzepten der OOP. Obwohl das Verständnis des OO-Konzepts die elementare Basis ist, um objektorientiert programmieren zu können, benötigt ein Programmierer für die Praxis selbstverständlich Kenntnisse über die genaue Syntax einer Sprache. Auf den folgenden Seiten erfahren Sie Genaueres zum grundsätzlichen Programmaufbau bei Java (Klassenbildung), zur konkreten Erzeugung von Objekten und zum Umgang mit Vererbung und Assoziationen sowie Modifizierern. Auch in diesem Kapitel wollen wir jedoch die Erklärung sprachsyntaktischer Java-Details wie Datentypen und Variablen, Operatoren, Literale, verschiedene Anweisungstypen (Kontrollflussanweisungen und Schleifen) erst einmal außen vor lassen, um den reinen OO-Charakter von Java in den Vordergrund zu stellen.
3.1 Was ist Java? Bevor wir mit Java praktisch anfangen, klären wir kurz, was Java genau ist. Java ist zunächst eine Programmiersprache, aber der Begriff bezeichnet mehr. Java bildet eine ganze Plattform zur Ausführung von stabilen, sicheren und leistungsfähigen Programmen unabhängig vom zugrunde liegenden Betriebssystem.
3.1.1
Etwas Historie
Java ist eine Erfindung der Firma Sun Microsystems (http://www.sun.com). Die Geschichte von Java geht bis ins Jahr 1990 zurück. Zu diesem Zeitpunkt versuchte Sun, im Rahmen eines Projekts mit Namen Green den zukünftigen Bedarf an EDV zu analysieren, um für die weitere Ausrichtung des Unternehmens einen zukunftsträchtigen Markt zu lokalisieren. Hauptvermutung des
Objektorientierte Programmierung in Java
55
3 – Objektorientierte Programmierung mit Java
Green-Projekts war, dass die Computerzukunft weder im Bereich der Großrechner noch bei PCs oder Kleincomputern in der damals aktuellen Form zu sehen sei. Der Consumerbereich der allgemeinen Elektronik (Telefone, Videorecorder, Waschmaschinen, Kaffeemaschinen und eigentlich alle elektrischen Maschinen, die Daten benötigten) wurde als der (!) Zukunftsmarkt der EDV prognostiziert. Und dieser Markt basiert auf einem extrem heterogenen Umfeld mit den unterschiedlichsten Prozessoren bzw. grundverschiedenen Kombinationen von Hard- und Softwarekomponenten. Das Green-Projekt prophezeite die Zukunftschance der EDV schlechthin, wenn Sun dafür eine gemeinsame Plattform schaffen und damit frühzeitig einen Standard festlegen könnte. Wichtigste Anforderungen an eine solche auf allen denkbaren Systemen lauffähige Plattform war zunächst eine erheblich größere Fehlertoleranz als sie bei allen bis dahin vorhandenen Plattformen gegeben war. Dazu sollte eine bedeutend bessere Stabilität realisiert werden. Die Plattform musste deshalb sowohl ein neues Betriebssystem oder zumindest eine neue Betriebssystemergänzung für alle populären Betriebssysteme bereitstellen als auch möglichst eine neue Programmiersprache, denn alle bis dahin existierenden Programmiersprachen wiesen zu große Schwächen in Hinblick auf die Stabilität auf. Ab dem Frühjahr 1991 gingen die Planungen in die Generierung eines Prototyps für eine solche universale Plattform über. Als Name für dieses neue System wurde Oak (Eiche) gewählt. Bezüglich der Wahl des Namens kursieren diverse Gerüchte. Eine seriöse Erklärung für Oak ist, dass es die Abkürzung für Object Application Kernel war. 1992 präsentierte das Green-Team erste Ergebnisse. Die Zeit war jedoch noch nicht reif für die neue Technik. Allerdings bewahrte die Entwicklung des WWW Oak vor dem Vergessen. Das WWW wurde etwa 1994 als Zielplattform für ein weiterentwickeltes Oak erkannt, das 1995 unter einem neuen Namen – Java – der Öffentlichkeit vorgestellt wurde. Die speziellen, für das Internet aufbereiteten Java-Anwendungen nannte man Applets und deren phänomenaler Erfolg in den ersten Jahren ist hinlänglich bekannt. Dazu wurde gleich bei der Einführung von Java ein zugehöriges und vor allem kostenloses Paket von Entwicklungs-Tools vorgestellt – das JDK 1.0. 1997 folgte das erste bedeutende Update mit der Version 1.1 des JDK und der zugehörigen Plattform Java 1.1. Bereits Ende 1997 gab es dann die erste Betaversion des JDK 1.2. Java 1.2, wie nach den damaligen Veröffentlichungen die komplette Plattform heißen sollte, wurde kurz danach veröffentlicht. Aber erst im Dezember 1998 gab Sun die Finalversion des JDK 1.2 frei und führte zusätzlich ein voll-
56
Was ist Java?
ständiges Plattform-Update unter dem Namen Java 21 ein. Die massiven Veränderungen des Java-APIs zwischen der Version 1.1 und der neuen Version (samt diverser Inkompatibilitäten) erzwangen eine solche Namenspolitik jedoch. Der Oberbegriff „Java 2“ macht den Bruch zu den Vorgängerversionen deutlich. Mit der Einführung des JDK 1.2 und der Java-2-Plattform hatte Sun aber einen Stand geschaffen, von dem aus es bei der zukünftigen Entwicklung von Java weitgehend um die Verbesserung der Stabilität (ein rigoroses Qualitätssicherungsprogramm) und Performance (Vervollkommnung der virtuellen Maschine) ging. Davor lag der Hauptfokus auf dem Ausbau und der Entwicklung von neuen Features und der Beseitigung von Schwachstellen. Zwischen dem JDK 1.2 und dem derzeit aktuellen JDK 1.5 sind die Unterschiede nicht mehr so gravierend, weshalb Java 2 als gemeinsame Bezeichnung für alle Versionen fungieren kann. Vor allem sind die Veränderungen des JDK seit der Version 1.2 nur als echte Erweiterungen und Verbesserungen zu sehen, die keine gravierenden Inkompatibilitäten nach sich ziehen.
3.1.2
Die aktuelle Java-2-Plattform
Mittlerweile teilt man die Java-2-Plattform selbst nach ihrer Ausrichtung auf (das bedeutet im Wesentlichen nach den verschiedenen Klassen, die dem jeweiligen API zuzurechnen sind sowie den bereitgestellten Tools):
쐌 J2SE (Java 2 Standard Edition) bezeichnet das API und die Tools, die zum eigentlichen Java-Kern zu zählen und für Desktop-Anwendungen notwendig sind. 쐌 J2EE (Java 2 Enterprise Edition) ist der Oberbegriff für das API und die Tools für die Entwicklung von Enterprise- und Server-Anwendungen. 쐌 J2ME (Java 2 Micro Edition) umfasst das API und die Tools zur Entwicklung von Anwendungen für mobile Endgeräte. Für die Dinge, die wir im Rahmen dieses Buchs besprechen, genügt die Version J2SE. Diese stellt auch die Grundlage von J2EE und J2ME dar.
3.1.3
Die Struktur von Java
Java gehört von der Syntax her der C-Sprachfamilie an. Deshalb werden bezüglich der reinen Syntax fast alle Programmierer damit zurechtkommen, die 1. Nicht Java 1.2, wie es allgemein erwartet wurde.
Objektorientierte Programmierung in Java
57
3 – Objektorientierte Programmierung mit Java
eine andere Sprache dieser Familie (beispielsweise C, PHP, Perl oder JavaScript) beherrschen1. Allerdings ist das Lernen der reinen Sprachsyntax in der Regel der mit Abstand einfachste Teil. Das Konzept muss verstanden werden – insbesondere in Java, denn es wurde bei der Entwicklung von Java konsequent mit sämtlichen prozeduralen Erblasten gebrochen und der objektorientierte Aspekt an SmallTalk angelehnt. Dennoch ist Java nicht nur die Verbindung beider Techniken. Java wurde deshalb so erfolgreich, weil von Sun die Stärken der Vorfahren genommen und deren Schwächen beseitigt wurden. Hinzu kam die Ergänzung noch fehlender, sinnvoller Innovationen. Also ist Java ein eigenständiges Konzept, das man für eine effektive Programmierung genau verstehen muss. Sun charakterisiert Java wie folgt: Java: eine einfache, objektorientierte, dezentrale, interpretierte, stabil laufende, sichere, architekturneutrale, portierbare und dynamische Sprache, die Hochgeschwindigkeitsanwendungen und Multithreading unterstützt. Untersuchen wir einige Details dieser Aussage:
쐌 Der Begriff „einfach“ bedeutet nicht, dass Java einfach zu lernen ist. Zwar wird Java vor allem für C/C++-Progammierer in vielen Teilen vertraut erscheinen (obgleich Sie sich an vielen Stellen täuschen werden, wenn Sie nicht genau die Unterschiede beachten). Im Vergleich zum eng verwandten C/C++ ist Java auch bedeutend einfacher geworden, da auf viele komplexe Dinge wie explizite Pointer, Überladen von Operatoren oder manuelle Speicheraktionen verzichtet wurde. „Einfach“ sollten Sie jedoch unabhängig vom Lernaufwand sehen. Java an sich ist klein. Die Sprache Java ist insofern einfach, als es nur wenige Elemente und vor allem wenige Axiome2 gibt. Wenn man wenige – wenngleich oft abstrakte – Regeln berücksichtigt, baut die gesamte Java-Philosophie darauf auf. 쐌 Java ist eine sehr strenge objektorientierte Technologie, die die OO-Konzepte weiter und logischer durchzieht als viele vergleichbare OO-Sprachen. Dennoch wurden einige strenge Vorgaben der OO-Philosophie etwas eingeschränkt, um die Akzeptanz nicht unnötig zu reduzieren3. 1. Zumindest wird die reine Syntax zu lesen sein, weshalb wir auch aus didaktischen Gründen die Besprechung erst im nächsten Kapitel durchführen. 2. Grundregeln zum Aufbau des logischen Konzepts. 3. Das betrifft im Wesentlichen primitive Datentypen und die Erzeugung von Datenfeldern sowie Strings.
58
Was ist Java?
쐌 Java gilt als interpretiert und kompiliert zur gleichen Zeit. Der Quellcode wird vom Java-Compiler javac in einen binären Zwischencode (so genannten Bytecode) kompiliert1, der ein architekturneutrales und noch nicht vollständiges Object-Code-Format ist. Er ist noch nicht lauffähig und muss von einer Laufzeitumgebung interpretiert werden. Das passiert, wenn der Bytecode als Parameter an den Java-Interpreter java übergeben wird2. Da jede Java-Laufzeitumgebung plattformspezifisch ist, arbeitet das endgültige ausgeführte Programm auf dieser spezifischen Plattform. Dort werden alle Elemente hinzugebunden, die für eine spezielle Plattform notwendig sind. Da der letzte Teil der Übersetzung des Bytecodes von einem plattformspezifischen Programm auf der Plattform des Endanwenders ausgeführt wird, braucht ein Entwickler keine unterschiedlichen Programme für verschiedene Plattformen zu erstellen. Die Interpretation erlaubt zudem, Klassen zur Laufzeit zu laden, was die Grundlage für das dynamische Verhalten von Java ist. Wenn in einem Programm Funktionalität einer anderen Klasse benötigt wird, kann die .class-Datei vom Java-System dynamisch nachgeladen werden. 쐌 Java ist stabil in der Bedeutung von „zuverlässig“. Dies bedeutet, dass Java-Programme seltener abstürzen als in den meisten anderen Sprachen geschriebene Programme. Die gesamte Philosophie von Java ist darauf ausgerichtet. Techniken wie Datentypüberprüfung bereits während der Kompilierungsphase, kein direkter Zugriff auf den vollständigen Speicherbereich eines Computers, grundsätzliche Plattformferne oder permanente Sicherheitsüberprüfungen sorgen dafür. 쐌 Java ist sehr sicher. So ist die Unterbindung von direkten Speicherzugriffen nicht nur ein Stabilitätskriterium, sondern es trägt gleichfalls zur Sicherheit bei. Hinzu kommt, dass sich Bytecode-Anweisungen von implementierten Sicherheitsstellen (einem Security-Manager, den man permanent als Hintergrundprozess bei einem Programm laufen lassen kann) hervorragend überprüfen lassen.
1. Wie wir schon in Kapitel 1 gesehen haben. 2. Bei alternativen Situationen wie Java-Applets agiert das System genauso.
Objektorientierte Programmierung in Java
59
3 – Objektorientierte Programmierung mit Java
쐌 Java ist architekturneutral. Das bedeutet, es ist auf verschiedenen Systemen mit unterschiedlichen Prozessoren und Betriebssystemarchitekturen lauffähig. Der kompilierte Java-Bytecode kann auf jedem System ausgeführt werden, das die virtuelle Maschine implementiert. 쐌 Java ist portierbar, was bedeutet, durch zahlreiche Spezifikationen (etwa standardisierte Datentypen, die sich auf jeder Plattform gleich verhalten) kann auch Java-Quellcode auf alle denkbaren Plattformen übertragen und dort implementiert werden. 쐌 Java unterstützt die quasi gleichzeitige, parallele Ausführung von mehreren Prozessen (Multithreading), die innerhalb eines einzigen Programms laufen. Bei Java ist das Multithreading-Konzept voll integrierter Bestandteil der Philosophie. Der hoch entwickelte Befehlssatz in Java, um Threads zu synchronisieren, ist in die Sprache integriert, macht diese stabil und einfach in der Anwendung.
3.2 Einige Grundlagen zur Java-Syntax Um bei der reinen Syntax von Java nicht immer wieder die gleichen zwingenden Einzelheiten betonen zu müssen, sollen einige Gegebenheiten zur Kodierung vorangestellt werden:
쐌 Jede Java-Anweisung endet mit einem Semikolon. 쐌 Eine Java-Anweisung kann sich über mehrere Zeilen erstrecken, aber Strings (in Hochkommata eingeschlossene Zeichen) dürfen nicht auf mehrere Zeilen verteilt werden. Das gilt auch für Bezeichner und andere Schlüsselbegriffe, die nicht getrennt werden dürfen. 쐌 Groß- und Kleinschreibung ist relevant! Das gilt uneingeschränkt im Quelltext, aber auch beim Aufruf der JDK-Tools, wobei man hier im Grunde genauer die Situationen spezifizieren müsste. Um die Sache nicht unnötig zu verkomplizieren – beachten Sie einfach konsequent Groß- und Kleinschreibung. 쐌 Selbstverständlich unterstützt Java die Blockbildung (Zusammenfassung) von Anweisungen. Die geschweiften Klammern umschließen einen Block von Anweisungen. Das Zeichen { öffnet einen Block und } schließt ihn wieder. 쐌 Quelltexte werden in der Programmierung meist eingerückt, um die Lesbarkeit zu erhöhen. Ineinander geschachtelte Strukturen sind so bezüg-
60
Einige Grundlagen zur Java-Syntax
쐌 쐌
쐌
쐌
lich ihres Beginns und Endes besser zu erkennen. Das hat in Java keinen negativen Einfluss auf die Effizienz und Größe des kompilierten Bytecodes. Überflüssige Leerzeichen, Tabulatoren und Zeilenumbrüche werden vom Compiler wegoptimiert. Die Reihenfolge und der Ort1 der Deklaration von Variablen und Methoden spielt in Java keine Rolle (im Gegensatz zu einigen anderen Programmiersprachen). Java basiert auf Unicode. Damit können zusätzliche Zeichen kodiert werden, die nicht im lateinischen/englischen Alphabet enthalten sind. Zeichenketten nehmen zwar doppelt so viel Platz wie im ASCII-Format ein, jedoch wird die Internationalisierung leichter. Die Unicode-Spezifikation besteht aus Tausenden von Zeichen. Da jedoch die ersten 256 Zeichen dem normalen ASCII-Zeichensatz entsprechen, brauchen Sie sowieso normalerweise darauf kaum Rücksicht zu nehmen. Sie können einfach wie bisher die Zahlen und Buchstaben für Variablen-, Methodenoder Klassennamen verwenden. Speichern Sie in einem ASCII-Editor wie Notepad aber explizit keinen Unicode, denn sonst wird der Compiler – falls er nicht durch entsprechende Optionen instruiert wird – den Unicode „aufblasen“, was zu zahlreichen Fehlern führt. Durch die Unicode-Darstellung von Zeichen ist es in Java erlaubt, Umlaute und andere Sonderzeichen in Bezeichnern zu verwenden. Ich würde aber raten, davon weitgehend Abstand zu nehmen. Das mag einerseits traditionelle Gründe haben, aber es würde auch bei erfahrenen Programmierern aus anderen Sprachen überflüssige Irritationen auslösen. Kommentare in Java treten in drei Formen auf. Ein einzeiliger Kommentar reicht von der Zeichenfolge // bis zum Ende der Zeile, während sich die Kommentare /* ...*/ und /** ... */ über mehrere Zeilen erstrecken können. Der Kommentar /** ... */ ist ein so genannter javadocKommentar, denn er wird von dem Dokumentationstool javadoc zum automatischen Aufbau einer Onlinedokumentation herangezogen.
1. Der Ort innerhalb der Klassenebene. Das bedeutet nicht, dass eine Methode außerhalb einer Klasse oder eine Methode in einer anderen Methode deklariert werden darf.
Objektorientierte Programmierung in Java
61
3 – Objektorientierte Programmierung mit Java
3.3 Erstellen von Klassen in Java Sie wissen aus dem letzten Kapitel, was Objekte sind und wie Klassen und Objekte in Beziehung zueinander stehen. Java setzt den objektorientierten Ansatz konsequenter um als viele vergleichbare Programmiersprachen. Java ist in seiner Philosophie bis ins Detail objektorientiert. Das hat massive Konsequenzen. Es gibt in Java beispielsweise keine globalen Variablen. Diese würden außerhalb von Objekten existieren und das ist explizit unmöglich. Auch Strings und Arrays sind in Java Objekte. Ebenso werden Ereignisse, Ausnahmen oder Fehler durch spezifische Objekte repräsentiert. Selbst Klassen sind im Java-Konzept wiederum Objekte, eben besondere Objekte. Sie werden als Instanzen über so genannte Metaklassen erzeugt, die in Java eine abstrakte, nicht greifbare Konstruktion sind1. Sie halten aber das gesamte Konzept schlüssig. Insbesondere kann damit vollkommen konsistent die Punktnotation auf den Fall von Klassen und deren Methoden und Variablen angewandt und grundsätzlich von Objekten gesprochen werden, ohne genau den Fall einer Klasse und eines Objekts zu trennen. Auch ein Java-Programm selbst ist als Objekt zu verstehen. Es existiert im Hauptspeicher des Computers, solange das Programm läuft. In Java muss im Grunde nur eine Ausnahmesituation für die Aussage „Alles ist ein Objekt“ beschrieben werden. Das sind primitive Datentypen. Auf die hat Sun bei der Konzeption von Java nicht verzichtet2, um prozedurale Programmierer nicht vollkommen vor den Kopf zu stoßen. Aber auch primitive Datentypen sind mittels so genannter Wrapper-Klassen in das Konzept eingebunden. Damit werden für jeden primitiven Datentyp zugehörige Objekte bereitgestellt.
3.3.1
Klassen schreiben
Sämtlicher Java-Code wird aus Klassen erzeugt. In Java kann man das deutlich erkennen. Jede Klasse im Java-Code beginnt mit dem Schlüsselwort class, dem höchstens Modifizierer vorangestellt werden können3. Verdeutlichen wir uns das mit einem ersten Java-Beispiel. 1. In Smalltalk kann man Metaelemente hingegen definieren. 2. Smalltalk verzichtet auch auf primitive Datentypen. 3. So genannte import-Anweisungen und package-Anweisungen, die am Beginn einer Quelltextdatei stehen können, stellen keinen Widerspruch dar, ebenso spezielle Klassen mit Namen Schnittstellen, die mit interface beginnen. Dazu kommen wir aber noch genauer.
62
Erstellen von Klassen in Java class HalloWelt { public static void main(String[] args){ System.out.println("Hallo Welt"); } }
Das Beispiel zeigt die grundsätzliche Vorgehensweise, wie Sie in Java eine Klasse definieren. Am Beginn steht das Schlüsselwort class, gefolgt von einem Bezeichner für die Klasse. Im Inneren finden Sie eine Methode mit Namen main(). Jedes Java-Programm benötigt zwingend eine solche main()Methode, deren Signatur genauso aussehen muss wie in dem Beispiel. Ein UML-Klassendiagramm zu unserem Beispiel sehen Sie in Abbildung 3.1.
Abb. 3.1: Das UML-Diagramm zum ersten Java-Beispiel
Die Anweisung System.out.println("Hallo Welt"); können Sie sich zunächst einfach als Ausgabe in der Konsole vorstellen. Damit wird der als Parameter angegebene Text ausgegeben. Wenn Sie den Quelltext in einem Editor eingeben haben, müssen Sie den Quellcode speichern, daraus Bytecode erzeugen und dann den resultierenden Bytecode im Rahmen der JVM interpretieren lassen. Gehen wir das Verfahren Schritt für Schritt durch.
Der Name der Quelltextdatei Zuerst stellt sich die Frage, wie die Java-Datei heißen soll? Aus dem ersten Kapitel sollte noch bekannt sein, dass Java-Quellcode immer die Dateierweiterung .java hat. Dabei ist die Groß- und Kleinschreibung unbedingt zu beachten. Aber wie lautet der Namensstamm der Quelltextdatei? Etwa HalloWelt? Oder sonst irgendwie? Es ist egal! Sie können die Datei HalloWelt.java oder auch Mausi.java nennen. Der Compiler erzeugt aus einer Quelltextdatei eine Bytecode-Datei, deren Name sich aus dem Bezeichner der Klasse ergibt. Also wird in jedem Fall bei der Übersetzung des Quellcodes eine Datei mit Na-
Objektorientierte Programmierung in Java
63
3 – Objektorientierte Programmierung mit Java
men HalloWelt.class entstehen. Das ist vollkommen unabhängig vom Namen der Quellcodedatei. Testen Sie das einfach, indem Sie die Quellcodedatei unter einem beliebigen Namen (nehmen wir als Beispiel ErstesProgramm.java) speichern und dann mit dem Compiler übersetzen. Das ginge in unserem Fall so: javac ErstesProgramm.java
Beachten Sie auf jeden Fall die Groß- und Kleinschreibung. Wenn Sie sich die resultierende Bytecode-Datei ansehen, werden Sie erkennen, dass diese HalloWelt.class heißt. Das Programm können Sie wie folgt ausführen: java HalloWelt
Beachten Sie auch hier wieder auf jeden Fall die Groß- und Kleinschreibung und den Verzicht auf die Dateierweiterung! Wenn Sie das Programm ausführen, werden Sie in der Konsole die Ausgabe des Texts „Hallo Welt“ sehen (siehe Abbildung 3.2).
Abb. 3.2: Die Quelltext- und die Bytecode-Datei sowie die Ausgabe des ersten Java-Programms
64
Erstellen von Klassen in Java
Vielleicht haben Sie jetzt ein ungutes Gefühl, weil Sie bereits etwas von einem Zusammenhang zwischen dem Namen der .java-Datei und dem Klassenbezeichner gehört haben. Wenn eine Klasse als öffentlich1 deklariert wird (mit dem vor dem Schlüsselwort class platzierten Schlüsselwort public), muss die .java-Datei einen Namensstamm haben, der identisch mit dem Bezeichner der Klasse ist. Da in unserem Beispiel das aber nicht der Fall ist, ist die Wahl des Namens der .javaDatei wirklich frei. Eine unmittelbar zwingende Folge ist, dass in einer einzigen .java-Datei zwar beliebig viele Klassen definiert werden dürfen2 und der Compiler bei der Übersetzung für jede Klassendefinition eine eigene Bytecode-Datei mit dem Namen des Klassenbezeichners erzeugt, aber nur eine Klasse in einer Quellcodedatei öffentlich sein darf!
main() Kommen wir noch einmal auf die main()-Methode in unserem Beispiel zurück. Die letzte Klasse war explizit eine Programmdefinition. Aber nicht jede Klasse ist ein Programm. Ganz im Gegenteil – die meisten Klassen werden nur Hilfsmittel sein, die im Rahmen eines Programms verwendet werden. Das gesamte Standard-API von Java mit seinen Tausenden von Klassen besteht aus solchen Klassen, die kein Programm darstellen. Umgekehrt gilt aber, dass jedes Java-Programm eine Klasse mit einer main()-Methode benötigt, die genauso aussehen muss wie in unserem Beispiel. Gehen wir einmal den Ablauf eines (normalen) Programms unter Java durch:
쐌 Der Interpreter wird aufgerufen und erhält als Übergabeparameter den Namen der Klasse mit einer main()-Methode. Er startet die JVM, in der die Klasse interpretiert wird. 쐌 Der Interpreter sucht in der Klasse nach einer Methode mit der Signatur public static void main(String[] args). Findet er diese Signatur nicht, bricht der Interpreter mit einer Fehlermeldung ab. Findet er sie, wird das Programm mit dem ersten Befehl innerhalb der main()-Methode gestartet.
1. Was das heißt, haben wir im letzten Kapitel besprochen – die Klasse kann von jeder Stelle aus verwendet werden. 2. Was man in der Praxis aber niemals machen sollte.
Objektorientierte Programmierung in Java
65
3 – Objektorientierte Programmierung mit Java
쐌 Alle Anweisungen in der main()-Methode werden der Reihe nach abgearbeitet. Bei Bedarf lädt der Interpreter weitere Klassen dynamisch nach. 쐌 Nach der letzten Anweisung in der main()-Methode wird das Programm beendet. Objektorientiert kann man das wie folgt ausdrücken: Der Interpreter erzeugt aus den das Programm repräsentierenden Klassen ein Objekt im Hauptspeicher des Rechners, von dem aus alle anderen Objekte erzeugt und dann verwendet werden. Ist das Programm beendet, wird das das Programm repräsentierende Objekt wieder aus dem Hauptspeicher entfernt. Bei anderen Formen von Java-Applikationen wie Java-Applets oder Java-Servlets läuft der Vorgang zwar im Detail etwas anders, aber dennoch verwandt ab. Ein Programm als Objekt im Hauptspeicher Sie haben also hier bereits einen Weg gesehen, wie aus einer Klasse (wenngleich einer sehr besonderen) ein Objekt (dasjenige, das das Programm selbst repräsentiert) entstehen kann. Im Allgemeinen reicht das nicht, denn wie gesagt – das ist nur das Verfahren, wie ein Programmlauf und das zugehörige Objekt in Beziehung gebracht werden.
3.3.2
Der konkrete Einsatz von Konstruktoren unter Java
Sie wissen mittlerweile, dass man Objekte mittels so genannter Konstruktoren erzeugt, von denen es in jeder (!) Klasse mindestens einen gibt. Jede JavaKlasse besitzt immer einen Default-Konstruktor, der jedes Mal dann zum Einsatz kommt, wenn Sie nicht selbst einen solchen in einer Klasse definieren (wir werden uns vorerst darauf zurückziehen). Eine sehr elegante und vor allem sehr praktisch anzuwendende Eigenschaft von Java ist, dass Konstruktoren in Java immer den gleichen Bezeichner wie die Klasse selbst haben müssen! Konstruktoren werden in Java in Verbindung mit einem Schlüsselwort new eingesetzt. Beachten Sie, dass Sie sich in Java nicht um die manuelle Speicherallokierung für ein Objekt kümmern müssen (oder können). Das wird automatisch durch einen Konstruktor erledigt. Erzeugen wir zuerst eine neue Klasse, die zwei Variablen definiert und mit einem Wert initialisiert sowie eine Methode deklariert. Dabei möchte ich erst einmal voraussetzen, dass Sie zumindest einigermaßen wissen, was eine Vari-
66
Erstellen von Klassen in Java
able ist. Auf Variablen unter Java gehen wir natürlich noch im Detail ein. Hier nur ein Vorgriff, soweit er notwendig ist. Eine Variable ist ein benannter Speicherplatz im Hauptspeicher des Computers, der frei mit (passenden) Werten gefüllt werden kann. Diese Werte lassen sich über den Bezeichner wieder auslesen. Ebenso soll die Erklärung, was genau bei der Deklaration der Methode abläuft, auf später verschoben werden (die allgemeinen Ausführungen aus Kapitel 2 genügen für diese Beispiele). An dieser Stelle soll gezeigt werden, wie aus der Klasse A ein Objekt erzeugt und dieses dann verwendet wird. class A { int x = 42; double y = 3.14; void test() { System.out.println("Hallo"); } }
Abbildung 3.3 zeigt ein UML-Klassendiagramm für diese Klasse.
Abb. 3.3: Das UML-Klassendiagramm für die Klasse A
In einer weiteren Klasse definieren wir eine main()-Methode, erzeugen in dieser ein Objekt der Klasse A und verwenden darüber die Variablen x und y sowie die Methode test(). Um ein Objekt zu erzeugen, müssen Sie dort Folgendes notieren: new A();
Beachten Sie die Klammern. A() bezeichnet nicht den Namen der Klasse, sondern den Namen der Konstruktormethode. Eine Klasse oder eine Variable kann nie ein Klammernpaar hinter dem Bezeichner stehen haben, eine Methode dagegen muss in Java immer (!) ein solches Klammernpaar dort stehen haben. Sie haben damit ein eindeutiges Unterscheidungskriterium (unabhängig von
Objektorientierte Programmierung in Java
67
3 – Objektorientierte Programmierung mit Java
der Verwendung im Quelltext, die meist ebenso eindeutig ist), ob es sich um den Klassenbezeichner oder den Konstruktor handelt. Damit Sie nun das so erzeugte Objekt auch nutzen können, wird meist1 eine Referenz darauf einer Variablen zugewiesen. Über deren Namen gelangen Sie dann an den Speicherplatz, in dem das Objekt abgelegt wird. Diese Variable muss für die Aufnahme eines ganz bestimmten Objekts eingerichtet werden. Sie bekommt dazu bei der Deklaration einen passenden Datentyp (dazu wird noch mehr folgen). In Java wird eine Variable so eingerichtet, dass zuerst der Datentyp notiert wird und dann der Bezeichner für die Variable. Wenn also eine Variable ein Objekt vom Typ der Klasse A aufnehmen soll, muss die Variablendeklaration (das ist die Einführung der Variablen im Programm) so erfolgen: A ob1;
Der Bezeichner ob1 ist der Name der Variablen, der das Objekt zugewiesen werden soll. Der gesamte Vorgang sieht also so aus: A ob1; ob1 = new A();
Nun kann man die Erzeugung des Objekts und die Variablendeklaration zu einer Quellcodezeile zusammenfassen. Dies macht vielen OOP-Einsteigern zwar Schwierigkeiten, ist aber gängige Java-Praxis und es ist wichtig, dass Sie sich daran gewöhnen: A ob1 = new A();
OOP-Einsteiger monieren jetzt oft, da würde ja auf beiden Seiten der Gleichung das Gleiche stehen. Das wären indessen zwei Denkfehler. Es handelt sich erst einmal nicht um eine Gleichung, sondern um eine Zuweisung. In dieser Zuweisung wird das, was auf der rechten Seite steht, dem, was auf der linken Seite steht, zugewiesen. Wichtiger im Moment ist jedoch, dass natürlich nicht das Gleiche auf beiden Seiten steht. Wir haben ja gerade besprochen, dass mit den Klammern die Konstruktormethode bezeichnet wird und ohne die Klammern die Klasse.
1. Es gibt auch die Möglichkeit, ein Objekt unmittelbar bei der Erzeugung (anonym) zu verwenden.
68
Erstellen von Klassen in Java
Bevor wir das Ganze nutzen, um unser Beispiel zu vervollständigen, fassen wir die Zusammenhänge mit Klassen, Instanzen davon und den Konstruktormethoden noch einmal abstrakter zusammen.
Die Deklaration des Pointers Wenn eine Instanz einer Klasse erstellt wird, muss ein Speicherbereich für verschiedene Informationen reserviert werden. Wenn Sie eine Variable für eine Instanz am Anfang einer Klasse deklarieren, dann sagen Sie dem Compiler damit lediglich, dass eine Variable eines bestimmten Namens in dieser Klasse verwendet wird. Eine Variable vom Typ einer Klasse ist in Java ein Alias für einen 32-Bit-Zeiger, eine Referenz auf einen Speicherbereich, in dem das konkrete Objekt abgelegt wird. Die Deklaration der Variablen ist dennoch eine gewöhnliche Variablendeklaration. Der Typ der Variablen ist vom Typ des Objekts, auf das damit referenziert werden soll. Die Syntax sieht folgendermaßen aus: < Klasseninstanz >;
Die Erzeugung eines Objekts Um ein konkretes Objekt verfügbar zu haben, ist es notwendig, dass Sie der Variablen zusätzlich unter Verwendung des Operators new ein konkretes Objekt zuweisen. Die Zuweisung der konkreten Klasseninstanz mit dem Konstruktor erfolgt dann so: = new ();
Die beiden Schritte werden oft zusammen erledigt. Das sieht dann wie folgt aus: = new ();
Auf der rechten Seite steht der Name der Konstruktormethode, die immer mit Klammern zu notieren ist. Die Parameter darin sind optional. Wenn keine Parameter angegeben werden, spricht man von einem parameterlosen oder leeren Konstruktor. Andernfalls handelt es sich um einen parametrisierten Konstruktor.
Objektorientierte Programmierung in Java
69
3 – Objektorientierte Programmierung mit Java
Wenden wir uns der Vervollständigung des Beispiels zu. Die Klasse, die das eigentliche Programm repräsentieren wird, soll wie folgt aussehen: class ZweitesProgramm { public static void main(String[] args) { A ob1 = new A(); ob1.test(); System.out.println(ob1.x); System.out.println(ob1.y); } }
In der main()-Methode wird ein Objekt der Klasse A erzeugt und dann zuerst die Methode test() aus der Klasse A aufgerufen. Anschließend wird der Wert der Variablen x und dann der Variablen y aus der Klasse A ausgegeben. Die zweite Klasse ZweitesProgramm kann in der gleichen .java-Datei (was nicht zu empfehlen ist) oder in einer anderen .java-Datei definiert werden, die aber im gleichen Verzeichnis gespeichert werden sollte1. So kann in beiden Fällen die Klasse A direkt in der Klasse ZweitesProgramm verwendet werden. Wenn Sie nun die .java-Datei mit der Klasse ZweitesProgramm kompilieren, wird die .java-Datei mit der Klasse A automatisch mitkompiliert, wenn sie noch nicht kompiliert ist. Ebenso wird sie neu kompiliert, wenn sich Änderungen darin ergeben haben.
Abb. 3.4: Kompilieren und Ausführen der Klasse ZweitesProgramm 1. Wenn das nicht gemacht wird, muss dem Compiler über eine spezielle Angabe – den so genannten Classpath – mitgeteilt werden, wo sich die Klasse A befindet, wenn Sie ZweitesProgramm kompilieren wollen. Das wollen wir an dieser Stelle aber vermeiden.
70
Erstellen von Klassen in Java
3.3.3
Das Java-Speichermanagement und der Garbage Collector als Ersatz für Destruktoren
Wie jede Programmiersprache muss auch Java das Management des Hauptspeichers zur Laufzeit eines Programms bewerkstelligen. Konstruktoren sind ein Teil des Speichermanagements von Java. Java verfügt über ein ausgefeiltes Speichermanagement, das nicht nur die Allokierung von Speicher bei der Erzeugung von Objekten und Variablen betrifft, sondern auch die Speicherfreigabe. So gut wie sämtliche Speicherverwaltung erfolgt automatisch oder mit einfachster Syntax. Wenn Sie mit dem new-Operator und einem Konstruktor eine neue Instanz einer Klasse erstellen, belegt das Laufzeitsystem von Java dafür einen Teil des Speichers, in dem die zu der Klasse bzw. ihrer Instanz gehörenden Informationen abgelegt werden. Sie initiieren zwar damit für jedes neue Objekt eine Zuweisung von Speicherplatz, spezifizieren jedoch nicht die benötigte Größe des Speicherplatzes, sondern nur noch den Namen des benötigten Objekts oder können gar anonym arbeiten. Das bedeutet ebenfalls, dass mit der new-Anweisung bereits der richtige Datentyp zugewiesen wurde, wenn sie eine Referenz auf einen zugewiesenen Speicherbereich zurückgibt.
Die Speicherfreigabe Nun sollte ein Programm natürlich nicht unbegrenzt Speicherplatz belegen. Der Speicher muss also von einem Programm so verwaltet werden, dass jeder Speicherbereich, der nicht mehr notwendig ist, wieder freigegeben wird. In Programmiersprachen wie C/C++ muss sich ein Programmierer manuell darum kümmern und einen so genannten Destruktor als Gegenstück zum Konstruktor aufrufen. Wird das vergessen, wird der Speicher nicht freigegeben. Das Programm kann in einen ineffektiven oder gar instabilen Zustand laufen und sogar den Rechner mit ins Grab ziehen. Java besitzt nun aber gar keine expliziten Destruktoren. Stattdessen gibt die Java-Laufzeitumgebung Speicherplatz immer automatisch mit einer Art „Schredderfunktionalität“ frei – Garbage Collection oder auch Garbage Collector genannt –, wenn es keine Referenzen mehr auf ein Objekt gibt. Diese Funktionalität läuft bei jedem Java-Programm automatisch im Hintergrund als Thread mit niedriger Priorität.
Objektorientierte Programmierung in Java
71
3 – Objektorientierte Programmierung mit Java
System.gc() Die explizite Speicherfreigabe ist in Java schlicht und einfach überflüssig. Zwar kann die Speicherbereinigung mit der Methode System.gc() direkt manuell aufgerufen werden. Dies ist jedoch in der Regel weder notwendig noch zu empfehlen. Insbesondere können Sie diese Methode zwar aufrufen, aber das bedeutet nicht, dass sie auch unmittelbar ausgeführt wird. Wie bei allen Multithreading-Prozessen wird der Zeitpunkt davon abhängen, ob der Prozessor gerade Zeit dafür hat. Falls höher priorisierte Prozesse CPU-Zeit brauchen, wird das dann auf keinen Fall unmittelbar erfolgen und keinerlei Vorteile gegenüber dem automatischen Speicherbereinigungsprozess bringen – mit hoher Wahrscheinlichkeit stattdessen sogar Nachteile.
3.4 Klassen direkt nutzen Sie haben jetzt gesehen, wie Sie zwei oder mehr Klassen definieren und in einer davon eine der anderen nutzen können1. Sie erzeugen über den Konstruktor einer Klasse ein Objekt und greifen darüber auf die Eigenschaften oder Methoden der Instanz zu. Das ist aber nicht der einzige Weg, um Klassen zu nutzen.
Direkter Zugriff auf Klassen Sie können ebenso direkt Klassen und deren Bestandteile nutzen. Klassen sind ja in der Java-Philosophie nur besondere Objekte2. Deshalb kann das Verfahren vollkommen konsistent vom Fall allgemeiner Objekte darauf übertragen werden. Wie greifen Sie auf ein Objekt zu? In der Regel über den Namen einer Variablen, in der eine Referenz auf das Objekt gespeichert ist. Eine Klasse hat ja auch einen Namen und aus der objektorientierten Sicht ist das eine Referenz auf die Klasse, über die auf die Klasse selbst direkt zugegriffen werden kann. Allerdings kann man dort dann nur die Elemente nutzen, die ausdrücklich als Klassenelemente gekennzeichnet sind. Von der Syntax her kennzeichnet Java Klassenelemente – Variablen (bzw. Eigenschaften) oder Methoden, die zur Klasse gehören – mit einem vorangestellten Schlüsselwort static. Erweitern wir die Klasse A um zwei Klassenelemente (eine Variable und eine Methode): 1. Zumindest wenn sie von der aktuellen Klasse aus gefunden wird, was beispielsweise bei Klassen im gleichen Verzeichnis immer der Fall ist. 2. Das ist zwar eine Denkweise, die für Umsteiger aus anderen OOP-Sprachen etwas kritisch erscheint, aber voll konsistent im Konzept integriert ist.
72
Klassen direkt nutzen class A { int x = 42; double y = 3.14; void test(){ System.out.println("Hallo"); } static int z = 123; static void test2(){ System.out.println("Eine Klassenmethode"); } }
In der Klasse werden nun zusätzlich die Klassenvariable z und die Klassenmethode test2() deklariert.
Abb. 3.5: Das Klassendiagramm der erweiterten Klasse A – beachten Sie die unterstrichen dargestellten Klassenelemente
Erweitern wir nun auch die Klasse ZweitesProgramm so, dass in der main()Methode direkt über den Klassennamen auf die beiden Klassenelemente aus der Klasse A zugegriffen wird: class ZweitesProgramm { public static void main(String[] args) { A ob1 = new A(); ob1.test(); System.out.println(ob1.x); System.out.println(ob1.y); System.out.println(A.z); A.test2(); } }
Objektorientierte Programmierung in Java
73
3 – Objektorientierte Programmierung mit Java
Über A.z greifen Sie auf den Wert der Klassenvariablen z zu und A.test2() ruft die Klassenmethode auf.
Abb. 3.6: Die letzten beiden Ausgaben verwenden Klassenelemente
Wenn Sie versuchen, auf ein Element einer Klasse zuzugreifen, das nicht explizit dafür vorgesehen ist, wird der Compiler dies nicht übersetzen. Der Compiler meldet einen Fehler der folgenden Art: Cannot make a static reference to the non-static method test() from the type A
3.5 Klassenelemente versus Instanzelemente In Kapitel 2 wurde der Unterschied zwischen Instanz- und Klassenelementen erklärt, aber ein praktisches Beispiel mit Java ist sinnvoll, um den Grund für die Existenz beider Elementtypen genauer zu verstehen. Betrachten Sie die nachfolgende Klasse: class A{ String farbe1="Rot"; static String farbe2="Blau"; }
Die Klasse A enthält eine Instanz- und eine Klassenvariable vom Typ String, die jeweils mit einem Wert vorbelegt werden.
74
Klassenelemente versus Instanzelemente
Abb. 3.7: Das UML-Klassendiagramm der Klasse A
In der nachfolgenden Klasse werden mehrere Instanzen der Klasse A erzeugt. class B { public static void main(String[] args) { A ball1 = new A(); A ball2 = new A(); A ball3 = new A(); System.out.println("Der Farbwert 1 von Ball 1: " + System.out.println("Der Farbwert 2 von Ball 1: " + System.out.println("Der Farbwert 1 von Ball 2: " + System.out.println("Der Farbwert 2 von Ball 2: " + System.out.println("Der Farbwert 1 von Ball 3: " + System.out.println("Der Farbwert 2 von Ball 3: " + System.out.println("---------------------------"); System.out.println("Wir faerben Ball 1 um"); System.out.println("---------------------------"); ball1.farbe1="Gelb"; ball1.farbe2="Cyan"; System.out.println("Der Farbwert 1 von Ball 1: " + System.out.println("Der Farbwert 2 von Ball 1: " + System.out.println("Der Farbwert 1 von Ball 2: " + System.out.println("Der Farbwert 2 von Ball 2: " + System.out.println("Der Farbwert 1 von Ball 3: " + System.out.println("Der Farbwert 2 von Ball 3: " + } }
Das Beispiel abstrahiert die Umsetzung eines Balls bezüglich seiner Farben. Ein typischer Fußball ist zweifarbig. In der Klasse A werden die beiden Farben festgelegt – einmal als Klassenvariable, einmal als Instanzvariable. Dann werden drei Bälle als Objekte in der Klasse B erstellt. In den nachfolgenden Kontrollausgaben erfolgt die Ausgabe der beiden Farben der drei Bälle, indem über die jeweiligen Objektvariablen ball1, ball2 und ball3 auf die Werte zugegriffen wird. Anschließend werden bei dem Objekt ball1 beide Farben geändert und danach erneut jeweils beide Farben aller drei Bälle ausgegeben. Was ist passiert? Bei Ball 1 sind logischerweise beide Farben verändert. Aber auch bei den beiden anderen Bällen hat sich eine der Farben geändert. Und zwar diejenige, die als Klassenvariable definiert war! Die Instanzvariable ist für Ball 2 und Ball 3 unverändert geblieben!
Abb. 3.8: Eine Klassenvariable steht in der Klasse und allen ihren Instanzen zur Verfügung
Wenn Sie nun noch beachten, dass Klassen zur Definition von statischen Factory-Methoden (was wir nicht weiter verfolgen) sowie der Definition von Sammlungen einer gewissen Anzahl von Methoden, die auch ohne explizite Objekterzeugung bereitstehen sollen, dienen können, haben wir nun alle Argumente für die Notwendigkeit beider Elementtypen zusammen. Klassenvariablen können einmal an beliebigen Stellen genutzt werden, unabhängig davon, ob eine konkrete Instanz der Klasse existiert oder nicht. Mit anderen Worten – eine Klassenvariable ist über die Klasse oder eine Instanz davon zugänglich. In
76
Klassenelemente versus Instanzelemente
dem Beispiel wird die Klassenvariable über das Objekt ball1 verändert1. Eine Instanzvariable ist dagegen nur über ein Objekt verwendbar. Die konkrete Anwendung erfolgt immer über ein Objekt. Dieses letzte Beispiel hat ebenfalls deutlich gemacht, dass sich eine Änderung einer Klassenvariablen (egal, wie und wo sie erfolgt) in der Klasse und allen Instanzen auswirkt, während die Änderung einer Instanzvariablen für die Schwesterinstanzen der gleichen Klasse verborgen bleibt. Klassenvariablen sind eine Art klassenglobale Variablen, die einer Klasse samt sämtlicher daraus erzeugten Instanzen als gemeinsames Pinboard dient, worüber Informationen ausgetauscht werden können. Grundsätzlich sollte die Verwendung von Klassenelementen mit Vorsicht erfolgen, denn in der Regel ist es nicht sinnvoll, wenn Änderungen klassenglobal vorgenommen werden und sowohl die Klasse als auch alle Instanzen von einer Änderung betroffen sind2. Wenn Sie die Wahl haben, ob Sie Klassen- oder Instanzelemente verwenden, sollten Sie sich fast immer für Instanzelemente entscheiden. Für die Wahl von Klassenelementen sprechen außer der globalen Kommunikation nur wenige Argumente. Klassenvariablen sind beispielsweise noch bei Konstanten sinnvoll (denn da wird keine Änderung mehr vorgenommen) und in den Situationen, in denen eine Instanzerzeugung unnötigen Aufwand oder Ressourcenverbrauch bedeuten würde. Klassenelemente werden bekanntlich auch statische Elemente genannt und in Java entsprechend mit static gekennzeichnet. Diese Betrachtung zeigt, was sich technisch dahinter verbirgt. Während eine Instanzvariable aus der Klasse als Kopie in der Instanz erzeugt wird und in der Instanz diese Kopie vollkommen ohne Auswirkungen auf die Klasse oder andere Instanzen der Klasse verändert werden kann, wird bei einer Klassenvariablen nur eine Referenz (ein Zeiger) auf den Speicherbereich in der Klasse in die Instanz kopiert. Der eigentliche Speicherbereich mit dem Wert bleibt für alle Instanzen fest (sta-
1. Die Änderung einer Klassenvariable über eine Instanz ist zwar möglich (siehe das letzte Beispiel), sollte aber nach den offiziellen Java-Regeln in der Praxis vermieden werden. In einer Instanz haben wir ja nur eine Referenz auf den Speicherbereich in der Klasse und eine Wertänderung muss entlang der Referenz auf den Speicherbereich in der Klasse übertragen werden. Allgemein sollten Klassenvariablen nur über die Klasse und nicht über eine Instanz verändert werden. 2. Eine Änderung einer Klassenvariablen erzeugt quasi ein in allen Instanzen zu hörendes „Rauschen“.
Objektorientierte Programmierung in Java
77
3 – Objektorientierte Programmierung mit Java
tisch). Dementsprechend wird sich jede Änderung sowohl in der Klasse als auch in allen Instanzen auswirken.
3.6 Fremde Klassen verwenden Bisher haben wir nur selbst definierte Klassen verwendet. Das wird in der Praxis natürlich nicht so bleiben. Objektorientierung bezieht viel von seiner Leistungsfähigkeit dadurch, dass Sie Klassen verwenden, die andere Programmierer bereitstellen. In Java haben Sie explizit eine extrem umfangreiche Sammlung von Klassen mit vorgefertigten Funktionalitäten, die Sie natürlich ausführlich nutzen (das Java-Standard-API). Sie werden diese Klassen übrigens in der Regel nicht direkt in Form von .class-Dateien auf Ihrem Rechner finden. Sie sind gepackt in komprimierten Dateien (.jar-Dateien), die auf verschiedene Stellen in Ihrer Verzeichnisstruktur verteilt sind. Die Datei rt.jar1 beinhaltet dabei die wichtigsten Klassen der Java-Laufzeitumgebung.
Abb. 3.9: Im lib-Verzeichnis der JRE finden Sie wichtige .jar-Dateien – unter anderem rt.jar mit den wichtigsten Laufzeitklassen 1. Das steht für „RunTime“.
78
Fremde Klassen verwenden
3.6.1
Der Klassenpfad
Wenn bei einem Java-Programm andere Klassen benutzt werden, stehen immer alle aktuell bereits geladenen Quelldateien zur Verfügung. Der Einstiegspunkt für die Suche nach noch nicht geladenen Klassen durch JDK-Tools ist das Verzeichnis, das bei der Installation der JRE erstellt und entsprechend in die Suchstrukturen des Betriebssystems eingetragen wurde. Sie können zudem durch eine Aufrufoption –classpath ein bestimmtes Verzeichnis zum Durchsuchen nach notwendigen Klassen angeben. Wir wollen uns hier erst einmal auf die Klassen beschränken, die zum Standard-API gehören. Diese werden (weitgehend) direkt gefunden. Betrachten Sie das nachfolgende Beispiel, in dem eine Klasse Date aus dem Java-Standard-API verwendet wird. class A { public static void main(String[] args) { java.util.Date d = new java.util.Date(); System.out.println("Tag: " + d.getDay()); System.out.println("Monat: " + d.getMonth()); System.out.println("Jahr: " + d.getYear()); System.out.println("Stunden: " + d.getHours()); System.out.println("Minuten: " + d.getMinutes()); System.out.println("Sekunden: " + d.getSeconds()); } }
Abb. 3.10: Die Verwendung einer Klasse aus dem Java-Standard-API
In dem Beispiel wird über eine vom Java-API bereitgestellte Klasse Date das Systemdatum des Rechners abgegriffen. Das erzeugte Datumsobjekt stellt di-
Objektorientierte Programmierung in Java
79
3 – Objektorientierte Programmierung mit Java
verse Methoden bereit, die in dem Beispiel über das Objekt d genutzt werden. Beachten Sie, dass die zurückgegebenen Werte etwas von der deutschen Datumsnotation abweichen, was aber hier keine Rolle spielen soll. Wichtiger ist, dass über eine Pfadangabe java.util auf die Klasse Date zugegriffen wird. Das wird etwas später in diesem Kapitel genauer erläutert. Spielen wir noch ein weiteres Beispiel durch: class B { public static void main(String[] args) { javax.swing.JFrame f = new javax.swing.JFrame(); f.setTitle("Mein Fenster"); f.setDefaultCloseOperation(2); f.setSize(200,100); f.setVisible(true); } }
Das Beispiel erzeugt ein Objekt vom Typ javax.swing.JFrame. Dies ist ein grafisches Fenster (auf Basis einer Java-Technologie, die Swing genannt wird). Das Fenster wird erzeugt, die Titelzeile des Fensters gesetzt, die Schließaktion spezifiziert, die Größe festgelegt und letztendlich wird das Fenster angezeigt.
Abb. 3.11: Ein Swing-Fenster
3.7 Namensregeln Wenn Sie Klassen und Objekte konstruieren oder auf fremde Klassen und Objekte zugreifen wollen, ist der Schlüssel für den Zugriff (meist) der Klassenname bzw. der Bezeichner eines Objekts. Ebenso verwenden Sie Bezeichner für Variablen und Methoden. Die Namen sind in Java relativ frei zu wählen.
80
Namensregeln
3.7.1
Verbindliche Namensregeln
Es gibt in Java einige wenige Regeln, die die Vergabe von Bezeichnern beschränken:
쐌 Bezeichner dürfen im Prinzip eine unbeschränkte Länge haben (bis auf technische Einschränkungen durch das Computersystem). 쐌 Bezeichner dürfen nicht getrennt werden. 쐌 Bezeichner in Java müssen mit einem Buchstaben oder einem der beiden Zeichen _ oder $ beginnen. Es dürfen weiter nur Unicode-Zeichen oberhalb des Hexadezimalwerts 00C0 (Grundbuchstaben und Zahlen sowie einige andere Sonderzeichen) verwendet werden. Das bedeutet mit anderen Worten, jeder (!) Buchstabe eines beliebigen (!) im Unicode abgebildeten Alphabets kann verwendet werden. 쐌 Zwei Bezeichner sind nur dann identisch, wenn sie dieselbe Länge haben und jedes Zeichen bezüglich des Unicode-Werts identisch ist. Java unterscheidet deshalb Groß- und Kleinschreibung. 쐌 Selbst definierte Bezeichner dürfen keinem Java-Schlüsselwort gleichen und sie sollten nicht mit Namen von Java-Paketen identisch sein.
3.7.2
Namenskonventionen
Es gibt nun in Java neben den zwingenden Regeln für Bezeichner ein paar unverbindliche, aber gängige Konventionen für Klassennamen, Objektnamen und andere Elemente. Man sollte in der Praxis möglichst sprechende Namen verwenden. Wenn Sie eine Klasse erstellen, die ein Fenster generiert, wäre MeinFenster ein sprechender Name für die Klasse und mF1, mF2 und mF3 sprechende Namen für daraus erzeugte Objekte. Aber über diese recht schwammigen Regeln hinaus gibt es Konventionen, die ziemlich genau formuliert sind. Die Bezeichner von Klassen (und Schnittstellen, was eine ganz besondere Form von Klassen ist) sollten immer mit einem Großbuchstaben beginnen und anschließend kleingeschrieben werden. Konstanten werden vollständig großgeschrieben. Die Bezeichner von Objekten, Variablen oder Methoden sollten mit einem Kleinbuchstaben beginnen und anschließend weiter kleingeschrieben werden. Wenn sich ein Bezeichner aus mehreren Wörtern zusammensetzt, dürfen diese nicht getrennt werden. Die jeweiligen Anfangsbuchstaben der zusammengesetzten Begriffe werden jedoch innerhalb des Gesamtbezeichners großgeschrieben.
Objektorientierte Programmierung in Java
81
3 – Objektorientierte Programmierung mit Java
Sie müssen sich nicht an diese Konventionen halten. Sie sollten es aber dringend! Nur so kann jeder, der Ihren Quellcode liest, bereits über die Bezeichner Syntaxstrukturen eindeutig identifizieren. Außerdem passt dann die Logik Ihrer Bezeichner zu der überall sonst (im Java-API, aber auch in anderen Projekten) verwendeten Logik. Wenn Sie diese Konventionen einhalten, ist unmittelbar klar, ob es sich bei den – vollkommen aus dem Syntaxzusammenhang gerissenen – Bezeichnern um eine Klasse oder ein Objekt oder auch ein anderes Strukturelement handelt. In vielen professionellen Projekten werden zusätzlich zu den zwingenden Regeln und den offiziellen Konventionen ergänzende, projektverbindliche Regeln für Bezeichner aufgestellt.
3.7.3
Namensräume
Java stellt eine Technik zur Verfügung, mit der Namenskonflikte aufgelöst werden können, wenn es zwei identische Bezeichner im Quelltext gibt. Java arbeitet mit so genannten Namensräumen. Man versteht unter einem Namensraum einen Bereich, in dem ein bestimmter Bezeichner benutzt werden kann und wo er eindeutig sein muss. Namensräume gehören in Java einer Hierarchie an. Es gilt dabei die Regel, dass ein Bezeichner immer einen identischen Bezeichner in einem übergeordneten (umgebenden) Namensraum überdeckt. Außerdem trennt Java die Namensräume von lokalem und nichtlokalem Code. Die Hierarchie der Namensräume gliedert sich wie folgt:
쐌 Ganz außen (aus Sicht der Mengenlehre zu sehen) steht der Namensraum des Pakets, zu dem die Klasse gehört. 쐌 Danach folgt der Namensraum der Klasse. Dieser überdeckt den Namensraum des Pakets. 쐌 Es folgen die Namensräume der einzelnen Methoden. Dabei überdecken die Bezeichner von Methodenparametern die Bezeichner von Elementen der Klasse. 쐌 Innerhalb von Methoden gibt es unter Umständen noch weitere Namensräume in Form von geschachtelten Blöcken. Variablen, die innerhalb eines solchen geschachtelten Blocks deklariert werden, sind außerhalb des Blocks unsichtbar.
82
Zugriff auf das Objekt selbst und das Schlüsselwort this
Um einen Bezeichner zuzuordnen, werden die Namensräume immer von innen nach außen aufgelöst. Wenn der Compiler einen Bezeichner vorfindet, wird er zuerst im lokalen Namensraum suchen. Sofern er dort nicht fündig wird, sucht er im übergeordneten Namensraum. Das Verfahren setzt sich analog bis zum ersten Treffer (gegebenenfalls bis zur obersten Ebene) fort. Es gelten immer nur die Vereinbarungen des Namensraums, in dem der Treffer erfolgt ist.
3.8 Zugriff auf das Objekt selbst und das Schlüsselwort this In manchen Situationen möchte man in einer Klasse auf eine Instanz davon und zugehörige Instanzelemente zugreifen. Über die Punktnotation kann man auf Klassen bzw. Objekte und ihre Elemente zugreifen. Klassenelemente stehen überall bereit und sind damit in trivialer Weise zugänglich. Das hilft aber nichts bei Instanzelementen. Dort besteht natürlich das Problem, dass zum Zeitpunkt der Notation einer Klasse noch keine konkrete Instanz davon existieren kann und genauso selbstverständlich nicht bekannt sein kann, über welchen Bezeichner die konkrete Instanz später verfügbar gemacht wird. Möglicherweise wird eine Instanz sogar anonym eingesetzt. Das bedeutet, direkt an den Aufruf des Konstruktors mit vorangestelltem new wird ein Punkt notiert und eine verfügbare Methode oder Eigenschaft benutzt, etwa so: new javax.swing.JFrame().setVisible(true);
In allen beschriebenen Situationen besteht das Dilemma, dass eine Klasseninstanz nicht über einen individuellen Bezeichner angesprochen werden kann. Aber es gibt in Java das Schlüsselwort this zum Zugriff auf die aktuelle Instanz einer Klasse aus der aktuellen Instanz der Klasse heraus. Das Schlüsselwort this kann nur in der Implementierung einer nichtstatischen Methode verwendet werden. Es existieren mehrere Situationen, die den Gebrauch dieser Technik rechtfertigen:
쐌 Es gibt in einer Klasse zwei Variablen mit gleichem Namen – eine gehört als Instanzvariable zum Namensraum der Klasse, die andere zum Namensraum einer spezifischen Instanzmethode in der Klasse. Die Benutzung der Syntax this. ermöglicht es, innerhalb der spezifischen Instanzmethode eindeutig auf diejenige Variable zuzugreifen, die zum Namensraum der Klasse gehört. Objektorientierte Programmierung in Java
83
3 – Objektorientierte Programmierung mit Java
쐌 Das Argument für eine Methode oder der Rückgabewert ist eine Objektinstanz der aktuellen Klasse. 쐌 Es soll explizit eine Instanzmethode oder Instanzvariable der aktuellen Klasse aus einer anderen Methode heraus verwendet werden. Das funktioniert in der Regel auch ohne vorangestelltes this. Mit dem Schlüsselwort ist die Notation aber eindeutiger und zudem helfen bessere JavaEditoren mit einer Auswahlliste der erlaubten Methoden und Eigenschaften des über this referenzierten Objekts. Sie schreiben einfach this, gefolgt von einem Punkt, und der Editor bietet die gleiche Hilfe an, die bei Hinschreiben eines expliziten Objektnamens und einem Punkt angeboten würde (siehe Abbildung 3.12). Das hängt aber vom Tool ab. Man nutzt diese Technik zum anonymen Zugriff auf ein Objekt vor allem dann, wenn man die Funktionsweise von Konstruktoren modifiziert. Dort können Sie auch mit this() samt optionaler Parameter direkt einen anderen Konstruktor der gleichen Klassen aufrufen, wenn der Konstruktor überladen1 ist.
Abb. 3.12: In guten Java-Tools notieren Sie this und einen Punkt und erhalten eine Auswahlliste aller verfügbaren Methoden und Eigenschaften
Das Schlüsselwort this lässt sich allgemein im Quelltext an jeder Stelle verwenden, an der das Objekt erscheinen kann, dessen Methode gerade aktiv ist – etwa als Argument einer Methode, als Ausgabewert oder in Form der Punktnotation zum Zugriff auf eine Instanzvariable. In vielen Anweisungen steckt das Schlüsselwort wie gesagt implizit mit drin und man kann oft darauf verzichten, wenn die Situation eindeutig ist. 1. Zur Technik des Überladens kommen wir noch.
84
Zugriff auf das Objekt selbst und das Schlüsselwort this
this in praktischen Beispielen Die nachfolgenden Beispiele demonstrieren die Verwendung von this in verschiedenen Varianten. Die Klasse A zeigt, wie Sie innerhalb einer Methode auf eine Variable der Klasse zugreifen können. Beachten Sie, dass die Instanzvariable in der Klasse den gleichen Namen hat wie die als Parameter übergebene Variable. Ohne das vorangestellte this wären die Zuweisungen in der Methode mMethode(int x, String y) Selbstzuweisungen der lokalen Variablen. So bedeutet die Zeile this.x = x;, dass der Instanzvariablen x der Wert zugewiesen wird, der als Parameter der Methode übergeben wird. Dass der Name des Parameters identisch ist, stört nicht. Im Gegenteil – es ist gängige Praxis, die Bezeichner von Variablen der Übergabewerte an Methoden identisch zu den Bezeichnern von Instanzvariablen zu wählen, um eine logische Zuordnung bereits im Quelltext erkennen zu können. Java stellt dafür die Namensräume bereit, die Eindeutigkeit gewährleisten. public class A { int x = 0; String y = "Vorbelegung"; void mMethode(int x, String y) { // Zuweisung der Instanzvariablen mit den // Werten der gleichbenannten Parameter this.x = x; this.y = y; } void printwas() { System.out.println( "Wert der Instanzvariable y: " + y ); System.out.println( "Wert der Instanzvariable x: " + x ); } public static void main (String[] args) { // Erzeugen einer Instanz A ob1 = new A(); // Ausgabe der Instanzvariablen mit Defaultwerten ob1.printwas(); ob1.mMethode(123456,"Die TAN: "); // Ausgabe der Instanzvariablen nach der Zuweisung ob1.printwas(); } }
Objektorientierte Programmierung in Java
85
3 – Objektorientierte Programmierung mit Java
In der main()-Methode wird zuerst ein Objekt der Klasse A erstellt und über das Objekt die Methode printwas() aufgerufen. Diese gibt die Werte der beiden Instanzvariablen x und y aus. Diese haben beim ersten Aufruf noch ihre Defaultwerte. In der nachfolgend aufgerufenen Methode mMethode(int x, String y) setzen wir den Wert der Instanzvariablen x in der Klasse auf 123456 und die Instanzvariable y bekommt den Wert "Die TAN:". Diese neuen Werte geben wir dann über den erneuten Aufruf der Methode printwas() aus.
Abb. 3.13: Zugriff auf Instanzvariablen über this
Erstellen wir noch ein Beispiel, das this verwendet. Hier nutzen wir this, um aus einem Konstruktor auf das erst zur Laufzeit erzeugte Objekt (ein Frame) zugreifen zu können. Auf „diesem“ Frame-Objekt fügen wir Schaltflächen hinzu, legen für „dieses“ Frame-Objekt das Layout, die Defaultreaktion auf das Klicken auf den Schließbutton und die Größe fest und zeigen „dieses“ Frame-Objekt dann an. Für die verwendeten Swing-Methoden und -Konzepte müssen wir auf das letzte Kapitel und weiterführende Java-Literatur verweisen. public class B extends javax.swing.JFrame { javax.swing.JButton mBnt1 = new javax.swing.JButton(); java.awt.FlowLayout flwLt = new java.awt.FlowLayout(); javax.swing.JButton mBnt2 = new javax.swing.JButton(); public B() { this.setLayout(flwLt); this.mBnt1.setText("Ja"); this.mBnt2.setText("Nein"); this.add(mBnt1);
86
Wie Methoden und Eigenschaften in Java genau realisiert werden this.add(mBnt2); this.setSize(150,80); this.setDefaultCloseOperation(2); this.setVisible(true); } public static void main(String[] args) { new B(); } }
Abb. 3.14: Ein einfaches Fenster
Da das Frame im Konstruktor eindeutig identifizierbar ist, könnte auch durchgängig auf this verzichtet werden.
3.9 Wie Methoden und Eigenschaften in Java genau realisiert werden Wir wissen aus Kapitel 2 und den bisherigen Beispielen zumindest theoretisch, wie Eigenschaften und Methoden in Java realisiert und verwendet werden. Aber im Detail sollten noch einige Besonderheiten und Feinheiten besprochen werden. Grundsätzlich verfügt jedes Objekt in Java über bestimmte Eigenschaften und Methoden, die es bereitstellt. Es gibt kein Objekt in Java, das nicht irgendwelche Eigenschaften oder Methoden besitzen würde. Diese Aussage ist bei näherer Überlegung natürlich logisch. Ein solches Objekt wäre sinnlos. Aber auch wenn Sie diesen streng von der Nützlichkeit bestimmten Schluss nicht ziehen, lässt Java solche (wie gesagt nutzlosen) Objekte auf Grund seiner Konzeption nicht zu. Jede Klasse in Java geht auf die Klasse java.lang.Object zurück und erbt davon bereits diverse Methoden. Die Klasse java.lang.Object ist die Wurzelklasse sämtlicher Java-Objekte.
Objektorientierte Programmierung in Java
87
3 – Objektorientierte Programmierung mit Java
3.9.1
Zugriffsschutz
Wenn in einer Klasse Variablen und/oder Methoden definiert sind, können diese entweder nach außen zugänglich gemacht oder versteckt werden. Dies erfolgt über die Modifizierer private, protected und public. Den Modifizierer package gibt es in Java nicht in der Form, dass er einer Klasse oder einem Element vorangestellt wird. Stattdessen hat jede Klasse und jedes Element immer den Zugriffsstatus package, wenn kein Modifizierer vorangestellt wird. Sie sollten bei nach außen unsichtbaren Variablen beachten, dass diese streng genommen keine Eigenschaften eines Objekts sind, sondern nur private Variablen. Sie sehen also, dass es (etwas spitzfindige) Unterschiede zwischen Variablen und Eigenschaften eines Objekts gibt. Jede Eigenschaft ist eine Variable, aber nicht jede Variable muss eine Eigenschaft im engeren Sinn sein. Zugang zu allen diesen sichtbaren Elementen eines Objekts erhalten Sie – wie schon oft angewandt – über die Punktnotation, in der links vom Punkt das Objekt steht und rechts davon das Element, auf das man zugreifen will. Somit ist System.out der Zugang zum Element out im Objekt System. Und beachten Sie, dass ich vom Objekt System rede. Durch die vollkommen konsistente Philosophie von Java ist das in der Tat korrekt, obwohl System eine Klasse ist1. Das Objekt out wiederum ist eine Eigenschaft von System und stellt selbst Methoden bereit, zum Beispiel println(). Das Beispiel System.out.println(); zeigt ebenfalls, dass Zugriffe auf Objekte und Elemente über die Punktnotation verschachtelt werden können. Wenn etwa die aufgerufene Methode selbst ein Objekt zurückgibt oder die Eigenschaft ihrerseits ein Objekt ist, so kann darauf die Punktnotation weiter angewandt werden. Die Punktnotation wird dabei von links nach rechts bewertet.
3.9.2
Eigenschaften und Methoden definieren
Wenn man es genau betrachtet, besteht die gesamte Java-Programmierung daraus, Klassen zu schreiben und darin Methoden und Eigenschaften zu definieren.
1. Das können Sie allein daran erkennen, dass der Bezeichner mit einem Großbuchstaben beginnt. Diese Konvention wird im Java-API zu 100% durchgehalten.
88
Wie Methoden und Eigenschaften in Java genau realisiert werden
Eigenschaften, Variablen und Konstanten Allgemein wird eine Eigenschaft eines Objekts einfach darüber definiert, dass in einer Klasse der Typ der Eigenschaft festgelegt und dann ein Name vergeben wird. Das ist also eine gewöhnliche Variablendefinition. Etwa so: int a; Integer b; String meinText;
In der Regel weist man der Eigenschaft dann noch einen Wert zu und regelt die Sichtbarkeit mit vorangestellten Modifizierern. Weitere Modifizierer wie static regeln zusätzliche Details. Die Definition einer Variablen kann an einer beliebigen Stelle im Namensraum einer Klasse, aber auch in einer Methode (als lokale Variable) oder auch in einer Struktur wie einer Schleife (als schleifenlokale Variable) erfolgen.
Lokale Variablen und Konstanten Lokale Variablen verwenden keine Sichtbarkeitsmodifizierer und können auch nicht als static definiert werden. Ebenso gilt es bei lokalen Variablen zu beachten, dass diese keinen Defaultwert haben. Vor der Verwendung einer lokalen Variablen muss ein Wert zugewiesen werden. Als Modifizierer von lokal definierten Variablen ist ausschließlich final erlaubt. Dies definiert eine Konstante. Java besitzt keine Konstanten im üblichen Sinn und realisiert diese ausschließlich über das Voranstellen des Schlüsselworts final bei ansonsten weitgehend normalen Variablen. Zusätzlich müssen Sie dieser Variablen einen Anfangswert zuweisen (der sich dann auch nie mehr ändert – sonst hätten wir ja keine Konstante).
Methoden Die Aussagen zur Definition von Variablen gelten ziemlich analog für Methoden, wobei natürlich spezifische Details abweichen. Sie können an einer beliebigen Stelle im Namensraum einer Klasse eine Methode definieren, aber nicht innerhalb einer anderen Methode. Die Definition bzw. Deklaration erfolgt nach den Regeln, wie sie in Kapitel 2 genannt wurden. Allerdings können in Java noch einige weitere Feinheiten die Signatur erweitern.
Objektorientierte Programmierung in Java
89
3 – Objektorientierte Programmierung mit Java
Die Deklaration einer Methode sieht in Java generell folgendermaßen aus: [<Modifiziererliste>] ([<Parameterliste>]) [throws] [<ExceptionListe>]
Dabei ist alles in eckigen Klammern optional. Die Methodenunterschrift oder Methodensignatur besteht also aus mindestens dem Namen der Methode, dem Rückgabetyp und den Klammern.
Ausnahmen Die als letzte Angabe zu findende ExceptionListe steht für eine Liste mit so genannten Ausnahmen oder Exceptions. Exceptions bezeichnet ein JavaKonzept, wie auf nicht planbare Situationen reagiert werden kann, und gehören zum Konzept der vertragsbasierten Programmierung.
Rückgabewerte und return Rückgabewerte von Java-Methoden können von jedem erlaubten Datentyp sein, nicht nur primitive Datentypen, sondern auch komplexe Objekte. Eine Methode in Java muss immer einen Wert zurückgeben (und zwar genau den Datentyp, der in der Deklaration angegeben wurde), es sei denn, sie ist mit dem Schlüsselwort void deklariert worden. Das Schlüsselwort void bedeutet gerade, dass eine Methode explizit keinen Rückgabewert hat. Rückgabewerte werden in Java mit der Anweisung return zurückgegeben. Auf das Schlüsselwort folgt der gewünschte Rückgabewert. Bei der returnAnweisung handelt es sich um eine Sprunganweisung, die beim Aufruf unmittelbar eine Methode verlässt. Bei einer als void deklarierten Methode darf die return-Anweisung keinen Rückgabewert zurückliefern.
Parameter Die optionalen Parameter einer Methode sind Variablen, die in einer Methodendeklaration in der Signatur eingeführt werden und dann über diesen Namen im Inneren der Methode als lokale Variablen zur Verfügung stehen. Bei der Deklaration werden in der Methodenunterschrift der Typ und ein Bezeichner für jeden Parameter angegeben. Mehrere Parameter werden durch Kommata
90
Wie Methoden und Eigenschaften in Java genau realisiert werden
getrennt. Beim Aufruf werden die Bezeichnernamen durch Werte des entsprechenden Typs ersetzt. Beachten Sie, dass die Klammern unbedingt bei einer Methode auftauchen müssen, auch wenn keine Parameter an die Methode übergeben werden. In diesem Fall bleiben die Klammern leer.
Praktische Beispiele Es ist Zeit für einige weitere Beispiele. Das erste besteht aus vier Klassen. class A { int a = 3; static String b = "Eine Klassenvariable"; }
In der Klasse A werden zwei Eigenschaften definiert: eine Instanzvariable a vom Typ int und eine Klassenvariable b vom Typ String. class B { private int b = 1; String c = "Sichtbar"; void ausgabe1(int a) { System.out.println( "Methode ausgabe1() bekommt den Wert " + a + "\nuebergeben und addiert den privaten Wert " + b + " dazu. \nDas ist das Ergebnis: " + (a+b)); } }
In der Klasse B gibt es eine private Variable b vom Typ int. Diese ist von außen also nicht zugänglich. Die Instanzmethode ausgabe1() verwendet diese private Variable, indem der Wert zu dem int-Wert addiert wird, den diese Methode beim Aufruf als Parameter übergeben bekommt. Die Instanzvariable c ist vom Typ String und von außen (aus dem gleichen Paket) zugänglich. Beachten Sie die Sequenz \n. Damit können Sie in der Textkonsole einen Zeilenvorschub auslösen.
Objektorientierte Programmierung in Java
91
3 – Objektorientierte Programmierung mit Java class C { void ausgabe1(){ System.out.println( "Textausgabe der Methode ausgabe1()."); } void ausgabe2(String a){ System.out.println( "Die Methode ausgabe2() schreibt das, was man" + " ihr als Parameter uebergibt: \n" + a); } static int rechne(int a, int b){ return(a+b); } }
Die Klasse C enthält zwei Instanzmethoden. Die Methode ausgabe1() besitzt keinen Parameter und die Methode ausgabe2() einen Parameter vom Typ String. Des Weiteren enthält die Klasse C eine Klassenmethode mit Namen rechne(), die zwei int-Parameter beim Aufruf übergeben bekommt und mit return() das Ergebnis der Addition beider Parameterwerte zurückgibt. class D { public static void main(String[] args) { A o1 = new A(); B o2 = new B(); C o3 = new C(); System.out.println("o1.a: " + o1.a); System.out.println("o1.b: " + o1.b); System.out.println( "Zugang ueber die Klasse A (A.b): " + A.b); o2.ausgabe1(123); System.out.println(o2.c); o3.ausgabe1(); o3.ausgabe2("Parametertext an o3.ausgabe2()"); System.out.println("Das Rechnen ueber o3: " + o3.rechne(3,4)); System.out.println( "Das Rechnen ueber die Klasse C: " + C.rechne(4,77));; } }
92
Wie Methoden und Eigenschaften in Java genau realisiert werden
Die Klasse D ist das eigentliche Programm. In diesem werden in der main()Methode von den Klassen A, B und C Objekte erzeugt und darüber die jeweiligen offen gelegten Eigenschaften und Methoden (wenn notwendig, mit entsprechenden Parametern) aufgerufen.
Abb. 3.15: Die Verwendung von selbst definierten Methoden und Eigenschaften
Ein zweites Beispiel soll einfacher werden, aber dennoch Überraschungen beinhalten. Wir arbeiten nur mit einer einzigen Klasse. class A { void ausgabe1(){ System.out.println("Das ist eine Instanzmethode"); } static void ausgabe2(){ System.out.println("Das ist eine Klassenmethode"); } public static void main(String[] args) { A ob1 = new A(); ausgabe2(); ob1.ausgabe1(); } }
Objektorientierte Programmierung in Java
93
3 – Objektorientierte Programmierung mit Java
Das Beispiel sollte aus mehreren Gründen überraschen. In der Klasse, in der sich auch die main()-Methode befindet, werden mehrere zusätzliche Methoden definiert. Aber warum eigentlich nicht? Das Vorhandensein der main()Methode bedeutet nur, dass die Klasse – falls sie als Parameter an den Interpreter übergeben wird – als Programm gestartet werden kann. Was die Klasse sonst noch bietet, ist dadurch in keiner Weise eingeschränkt. Sie können in einer Klasse natürlich so viele Methoden definieren, wie Sie wollen. Wer die Klasse verwenden will, ist aber nicht gezwungen, alle Methoden anzuwenden. Was bedeutet die Zeile A ob1 = new A();? Es wird ein Objekt ob1 aus der Klasse A erzeugt. Warum ist es eine Erwähnung wert? Sie befinden sich innerhalb der Klasse A! Obwohl im ersten Moment möglicherweise der Vergleich mit Münchhausen nahe liegt, wie er sich am eigenen Schopf aus dem Sumpf zieht, ist das ein gravierender Denkfehler. Beachten Sie noch einmal die Beschreibung, was in dieser Zeile wirklich getan wird: Es wird eine Variable vom Typ einer Klasse erzeugt und ihr dann über die Konstruktormethode der Klasse ein Objekt dieser Klasse zugewiesen. Das ist alles. Nicht mehr und nicht weniger. Die Formulierung ist absolut exakt und darin taucht nirgendwo der Name der Klasse explizit auf. Das ist genau der springende Punkt. Ob es eine fremde, zugängliche Klasse oder die Klasse ist, in der man sich gerade befindet, ist vollkommen egal. Wozu muss man aber überhaupt ein Objekt der eigenen Klasse erzeugen? Sie sehen, dass in dem Beispiel über das erzeugte Objekt ob1 auf die Methode ausgabe1() zugegriffen wird. Aber warum ist das so? Die Methode ausgabe1() ist keine Klassenmethode und kann nur über eine Instanz der erzeugenden Klasse verwendet werden.
3.10 Vererbung in Java Was Vererbung im Allgemeinen in der OOP darstellt, haben wir in Kapitel 2 gesehen. Schauen wir uns an, wie Java diese genau realisiert. In Java geht jede Klasse auf die Wurzelklasse java.lang.Object zurück. Dies ist immer die oberste Klasse im Klassenbaum und eine Vererbung davon muss in Java niemals explizit notiert werden. Java verwendet explizit eine hierarchische Einfachvererbung und keine Mehrfachvererbung, wie es C/C++ etwa tut. Programme mit Einfachvererbung sind erheblich leichter zu warten als solche mit Mehrfachvererbung, müssen aber viel sorgfältiger konzipiert werden, da eine Umstrukturierung von Vererbungsabhängigkeiten schwierig ist.
94
Vererbung in Java
3.10.1 Erweiterung einer Klasse mit extends Wenn man in Java eine Klasse explizit als Subklasse einer Klasse angeben will, wird das mit dem Schlüsselwort extends erfolgen, das hinter dem Bezeichner der Klasse notiert wird und dem die Klasse folgt, die als Superklasse dienen soll. Das sieht so aus: class [NameDerSubKlasse] extends [NameDerSuperKlasse]
Damit spezifizieren Sie die Klasse, auf der Ihre neue Klasse aufbaut (die Superklasse). Durch die Erweiterung einer Superklasse machen Sie aus Ihrer Klasse zuerst eine Art Kopie dieser Klasse und ermöglichen gleichzeitig Veränderungen an dieser neuen Kopie.
3.10.2 Finale Klassen Als eine finale Klasse bezeichnet man eine Klasse, die nicht durch Spezialisierung weiterentwickelt werden kann – eine Blattklasse. Das Voranstellen des Modifizierers final vor das Schlüsselwort class beendet einen Zweig in der Vererbungshierarchie.
Beginn des Aufbaus eines API Ziehen wir zur Verdeutlichung der letzten Ausführungen wieder ein Beispiel heran. Wir starten mit einer obersten Klasse, die unmittelbar von java.lang. Object abgeleitet ist. Das muss wie gesagt nicht explizit notiert werden. An dieser Stelle beginnen wir mit der Realisierung eines größeren Projekts. Nachfolgend bauen wir ein API aus Klassen auf, die in einer Vererbungsbeziehung zueinander stehen. Nennen wir das API Bauernhof-API. Dieses API werden wir im Rest des Buchs immer weiter entwickeln, so dass es sukzessive ein API für die Abbildung eines landwirtschaftlichen Produktionsbetriebs mit verschiedenen Tieren wird, von denen bestimmte Eigenschaften und Funktionalitäten für den Kunden abgebildet werden sollen. Der Kunde, für den wir das API am Anfang entwickeln, soll einen Bauernhof mit einer Kuh, einem Schaf und einem Schwein besitzen. Die permanente Weiterentwicklung des APIs soll neben der Erklärung der reinen Java-Programmierung zur Demonstration von Techniken der OO-Methodik (OOA und OOD) sowie des Spiralenmodells inklusive der Umstrukturierung und Erweiterung bestehender Strukturen samt potenzieller Nebenwirkungen dienen. Dabei müssen wir auf Grund der Beschränkungen Objektorientierte Programmierung in Java
95
3 – Objektorientierte Programmierung mit Java
des Buchs insbesondere die OOA als auch die OOD stark komprimieren und weitgehend zusammenfassen sowie sehr vereinfacht durchführen. Für den Einstieg wird das API ad hoc vorgegeben und einfach festgelegt, dass unsere anfängliche Realisierung der bis dahin ermittelten Lösung eines vorgegebenen Problems entspricht1. Da es in unserem Problem um die Abbildung von Tieren geht, macht eine Sammlung aller Eigenschaften und Methoden in einer Klasse Sinn, die allen Tieren gemeinsam und für unsere Aufgabenstellung relevant sind. Zuerst sehen Sie diese Superklasse mit Namen Tier: public class Tier { public byte alter; public int futter; }
Den Kunden interessieren für alle seine Tiere das Alter und die Menge an Futter, die für ein Tier gebraucht wird. Natürlich ist jede Kuh ein Tier – aber mit gewissen speziellen Eigenschaften. Es macht Sinn, eine Kuh als eine Spezialisierung eines allgemeinen Tiers zu betrachten und die Spezialisierungen in einer eigenen Klasse darzustellen. Im Folgenden sehen Sie eine mögliche Spezialisierung in einer Subklasse, wenn den Kunden die durchschnittliche Milchmenge interessiert, die die Kuh pro Tag gibt: public class Kuh extends Tier { public int milch; }
Ein Objekt der Klasse Kuh stellt nicht nur die öffentliche Eigenschaft milch, sondern ebenso die vererbten Eigenschaften alter und futter bereit. In Abbildung 3.16 sehen Sie das UML-Klassendiagramm für die Vererbungshierarchie. Nun sollen in dem API zwei weitere Klassen implementiert werden, die Schafe und Schweine abbilden. Bei beiden ist die Eigenschaft milch uninteressant. Für ein Schaf interessiert den Kunden die Menge an Wolle, die ein Schaf liefert (als int-Wert) und für ein Schwein das Geschlecht2 (als boolean-Wert). 1. Wir nehmen an, ein erster Durchlauf der OOA und OOD habe schlicht und einfach zu der hier zu realisierenden Aufgabe geführt. 2. Für unser Zuchtprogramm brauchen wir einen Eber.
96
Vererbung in Java
Abb. 3.16: Die Klasse Kuh ist die Subklasse der Klasse Tier
Bei der Analyse der Situation bzw. dem Design der Klassen sollte auffallen, dass Schafe und Schweine eine für den Kunden interessante Gemeinsamkeit aufweisen. Das Gewicht interessiert ihn sowohl bei den Schafen als auch bei den Schweinen (als int-Wert). Was ist zu tun? Eine Superklasse soll diese Eigenschaft verallgemeinern. Da das Gewicht allerdings für eine Kuh uninteressant sein soll, wird die Eigenschaft nicht in die Klasse Tier aufgenommen. Eine weitere Klasse (SchwSch genannt) als direkte Subklasse von Tier wird zur direkten Superklasse der Klassen Schwein und Schaf. Das sieht dann in einem UML-Klassendiagramm wie in Abbildung 3.17 aus.
Abb. 3.17: Ein Klassenbaum in Java
Objektorientierte Programmierung in Java
97
3 – Objektorientierte Programmierung mit Java
Konkret implementiert gestaltet sich die Klasse SchwSch wie folgt: public class SchwSch extends Tier { public int gewicht; }
Die Klasse Schwein erweitert SchwSch und ergänzt die fehlende Eigenschaft geschlecht. public class Schwein extends SchwSch { public boolean geschlecht; }
Auch die Klasse Schaf geht auf SchwSch zurück und ergänzt eine Eigenschaft. public class Schaf extends SchwSch { public int wolle; }
Das API kann nun in einem Programm verwendet werden. In der nachfolgenden Klasse Bauernhof machen wir dies. Zuerst erzeugen wir in der main()Methode von den Klassen Kuh, Schaf und Schwein jeweils ein Objekt. Dann wird für jedes Objekt eine der als Eigenschaften bereitgestellten Instanzvariablen mit einem Wert versehen, der anschließend in Verbindung mit einigen erläuternden Texten ausgegeben wird. Beim Schwein babe verwenden wir explizit eine Eigenschaft, die von der Superklasse Tier vererbt wurde. public class Bauernhof { public static void main(String[] args) { Kuh helga = new Kuh(); Schaf manfred = new Schaf(); Schwein babe = new Schwein(); helga.milch = 40; manfred.wolle = 13; babe.geschlecht = false; babe.alter = 5; System.out.println("Die Kuh Helga gibt " + helga.milch + " Liter Milch am Tag."); System.out.println("Mani liefert " + manfred.wolle + " Kilo Wolle.");
98
Pakete und die import-Anweisung System.out.println("Ist Babe ein Eber? " + babe.geschlecht); System.out.println("Babe ist " + babe.alter + " Jahre alt."); } }
Abb. 3.18: Aus dem API werden Klassen zur Erzeugung von Objekten verwendet
Beachten Sie, dass das API bisher nur die Vererbung und die Anwendung (das Kaufen) von Klassen aus einer Klassenhierarchie zeigen soll. Die vernünftige Anwendung von Modifizierern mit einem praxisorientierten Information Hiding sowie eine Strukturierung des API sind (noch) nicht realisiert.
3.11 Pakete und die import-Anweisung An verschiedenen Stellen in diesem Buch war bereits von Paketen die Rede. Pakete sind in Java – vereinfacht – die objektorientierte Abbildung von Verzeichnissen, in die Klassen einsortiert werden können. Pakete (engl. Packages) sind in Java also Gruppierungen von Klassen und Schnittstellen eines Verzeichnisses. Sie stellen das Java-Analogon zu Bibliotheken vieler anderer Computersprachen dar. Oder man kann es auch so sehen, dass Pakete zur Organisation von Klassen/Interfaces und zur Vermeidung von Namenskonflikten dienen und daher insbesondere beim Aufbau von Bibliotheken genutzt werden. Die Verzeichnisstrukturen enthalten im Java-Quelltext eine Darstellung über Bezeichner, die den physikalischen Verzeichnisnamen entsprechen. Der Zugriff auf Verzeichnisse und Unterverzeichnisse erfolgt gemäß der allgemeinen Regeln bei Objekten über die Punktnotation, etwa java.util. Dabei steht der Bezeichner java für ein Verzeichnis gleichen Namens und util spezifiziert darin ein Unterverzeichnis. Also steht java.util.Date für die Klasse Date im
Objektorientierte Programmierung in Java
99
3 – Objektorientierte Programmierung mit Java
Verzeichnis util, das sich wiederum in einem Verzeichnis java findet. Wenn Sie sich die bisherigen Beispiele ansehen, erkennen Sie dort mehrfach bereits den Zugriff per Punktnotation auf Paketstrukturen und darin enthaltene Klassen.
3.11.1 Die Suche nach Paketen Aber wo ist beispielsweise das Java-Standardverzeichnis java selbst zu finden? Wenn Sie auf andere Klassen – sowohl Standardklassen des Java-API, aber auch selbst geschriebene Klassen – zugreifen, wird das aktuell verwendete JDK-Programm (Interpreter, Compiler, Debugger, usw.) zuerst in den übergebenen Dateien und dann in den Bibliotheken Klassen des JRE suchen, die bei der Installation von Java angelegt und im Betriebssystem registriert wurden. Bei einem Element, das mit vorangestellten Verzeichnissen (Paketen) angegeben wird, wird dort vollkommen analog zuerst die Verzeichnisstruktur gesucht.
Der Klassenpfad Finden sich die gesuchten Klassen respektive die Verzeichnisstruktur nicht innerhalb dieser Stellen, wird auf einen so genannten Klassenpfad (Classpath) zurückgegriffen. Dieser Klassenpfad besteht standardmäßig (wenn er nicht global oder per -classpath-Option gesetzt wurde) aus dem aktuellen Verzeichnis. Dies ist dann eine rein physische Suche nach .class-Dateien oder .java-Dateien1, die auch in gepackten Verzeichnissen (.jar-Dateien) durchgeführt wird. Wenn nun aber ein Verzeichnis zum Durchsuchen nach einer Klasse feststeht, werden dort eventuell vorhandene Unterverzeichnisse nicht (!) nach benötigten Klassen durchsucht. Deshalb muss bei der Verwendung von Paketen der vollqualifizierte Name einer Klasse angegeben werden.
3.11.2 Die Zuordnung einer Klasse zu einem Paket Jede Klasse in Java ist Bestandteil eines Pakets. Wenn Sie keine Angaben zu einem Paket im Quelltext angegeben, werden die Klassen in der Datei dem anonymen Default-Paket zugeordnet. Die Klassen darin werden immer nur über 1. Das gilt dann, wenn kein sourcepath angegeben wurde und gegebenenfalls .java-Dateien noch kompiliert werden müssen.
100
Pakete und die import-Anweisung
den Bezeichner angesprochen. Klassen in anderen Paketen hingegen werden vollqualifiziert angesprochen. Der vollqualifizierte Name einer Klasse besteht immer aus dem Namen des Pakets, gefolgt von einem Punkt, eventuellen Unterpaketen, die wieder durch Punkte getrennt werden, bis hin zum eigentlichen Klassennamen. Um nun eine Klasse außerhalb des Default-Pakets verwenden zu können, muss einem Java-Tool wie dem Compiler gesagt werden, in welchem Paket er sie suchen soll. Etwa so: java.awt.Frame;
Das bedeutet, das Java-Tool sucht in dem Paket java nach dem Unterpaket awt (physisch ein Unterverzeichnis des Verzeichnisses java) und dort die Klasse Frame (die physische Datei Frame.class im Verzeichnis awt).
Namenskonventionen und Standardpakete Es gibt nun einige Namenskonventionen für Pakete. Die Standardklassen von Java sind in eine Paketstruktur eingeteilt, die an das Domain-System von Internetnamen angelehnt ist. Dabei sollte jeder Anbieter von eigenen Java-Paketen diese nach seinem DNS-Namen strukturieren, nur in umgekehrter Reihenfolge. Wenn etwa eine Firma RJS mit der Internetadresse www.rjs.de Pakete bereitstellt, sollte die Paketstruktur de.rjs lauten. Eventuelle Unterpakete sollten darunter angeordnet werden. Selbst offiziell in dem Standard-API von Java enthaltene CORBA-Pakete halten sich daran (etwa org.omg.CORBA). Einzige (offizielle) Ausnahme sind die Klassen, die im Standardumfang einer Java-Installation enthalten sind. Diese beginnen mit java oder javax. Die Einhaltung dieser Vorschläge zur Benennung von eigenen Paketstrukturen ist nun nicht zwingend. Nur kann die Nichtbeachtung dazu führen, dass es in größeren Projekten oder mit anderen Projekten zu Namenskonflikten kommt. Bei der Auswahl der Namen für Pakete gelten ansonsten nur wieder die üblichen Regeln für Token. Grundsätzlich werden Paketbezeichner kleingeschrieben bzw. nur die Anfangsbuchstaben ab dem zweiten Begriff (bei zusammengesetzten Begriffen), aber das ist nur eine Konvention. Allerdings müssen Sie beachten, dass das dann auch bei den Verzeichnisnamen so eingehalten wird. Das erfolgt analog zu Dateinamen, wobei Groß- und Kleinschreibung natürlich auch relevant ist.
Objektorientierte Programmierung in Java
101
3 – Objektorientierte Programmierung mit Java
3.11.3 Importieren von Klassen Wenn Sie eine Klasse in einem bestimmten Paket benötigen, können Sie die Klasse vollqualifiziert notieren und gegebenenfalls direkt die Notation um einen gewünschten Methoden- bzw. Variablennamen erweitern. Mit einer solchen vollqualifizierten Notation ist jedoch die Lesbarkeit des Quelltexts nicht gerade gut und vor allem der Tippaufwand für sich wiederholende Quelltextpassagen sehr hoch – insbesondere wenn man an zahlreichen Stellen Dateien aus einem Verzeichnis benötigt. Dies macht eine einfachere und schnellere Technik notwendig.
Die import-Anweisung Um eine Klasse eines fremden Verzeichnisses respektive Pakets wie eine Bibliothek nutzen zu können, kann man die Klasse oder das ganze Paket vorher importieren. Das geschieht durch eine import-Zeile, die vor der Definition irgendeiner Klasse in der .java-Datei stehen muss. Wenn Sie in einem Paket selbst eine andere Klasse importieren wollen, muss die import-Anweisung nach der package-Anweisung folgen (darauf kommen wir gleich zurück). Sie können eine Klasse importieren, indem Sie die gesamte Punktnotation (das Paket, zu dem sie gehört, und die Klasse selbst) am Anfang einer Datei mit dem Schlüsselwort import angeben. Danach können Sie die Klasse direkt mit ihrem Namen ansprechen. In unserem Beispiel würde das wie folgt aussehen: import java.awt.Date;
In diesen Fall können Sie später im Quelltext auf die Klasse Date einfach über ihren Namen zugreifen, ohne den vollqualifizierten Pfad notieren zu müssen. Etwa so: Date a = new Date();
java.lang Es gibt nur ein Java-Paket, das Sie nie explizit importieren müssen. Das ist java.lang. Dieses Paket wird vom Laufzeitsystem immer automatisch importiert und stellt die Basis des gesamten Java-Konzepts dar. Darin finden Sie so wichtige Klassen wie Object oder String. Aber auch Wrapper-Klassen zur Konvertierung und die Klasse System sind dort enthalten.
102
Pakete und die import-Anweisung
import versus include Eine import-Anweisung dient nur dazu, Java-Klassen im Quellcode über einen verkürzten Namen innerhalb der aktuellen Bezugsklasse zugänglich zu machen und damit den Code zu vereinfachen. Sie ist – trotz des irreführenden Namens – kein echter Import im Sinn einer include-Anweisung in C und liest Klassen nicht ein. Da das Importieren von Elementen in Java also kein echter Import in dem Sinne ist, dass das resultierende Programm alle angegebenen Klassen irgendwie verwalten muss, sondern nur eine Pfadangabe, können Sie in eine Java-Klasse beliebig viele Pakete und Klassen importieren. Der resultierende Bytecode wird weder größer noch sonst ineffektiver. Nicht explizit benötigte Klassen werden vom Compiler wegoptimiert. Das nennt man type import on demand.
Import von mehren Klassen Es gibt also in der Regel in einer Quelltextdatei mehrfache import-Anweisungen. Sie müssen alle hinter der optionalen Anweisung package (falls die definierten Klassen selbst einem Paket zugeordnet werden sollen, sonst fehlt die package-Anweisung – siehe unten) und vor der ersten Klassendefinition stehen. Wenn Sie mehrere Klassen aus einem Paket benötigen, arbeitet man sinnvollerweise mit Platzhaltern. Mittels einer solchen Wildcard – dem Stern * – kann ein ganzes Paket auf einmal importiert werden. Das geschieht wieder durch die import-Zeile, wobei nur der Klassenname durch den Stern ersetzt wird. Danach können Sie alle Klassen aus dem Paket direkt über ihre Namen ansprechen. Das Sternchen importiert jedoch keine (!) untergeordneten Pakete. Um also alle Klassen einer komplexen Pakethierarchie zu importieren, müssen Sie explizit auf jeder Hierarchieebene eine import-Anweisung erstellen. Der import-Befehl kennt in der Praxis zwei Varianten:
쐌 import <package>.; 쐌 import <package>.*; Die erste Form wird eingesetzt, wenn man gezielt auf eine Klasse zugreifen möchte und nur diese Klasse aus einem Paket benötigt. Die zweite Variante hat den Vorteil, dass alle Klassen aus dem eingebundenen Paket über den verkürzten Namen verfügbar sein. Viele bessere Java-Editoren unterstützen Anwender übrigens beim Import von Klassen und organisieren Imports teilweise sogar im Hintergrund.
Objektorientierte Programmierung in Java
103
3 – Objektorientierte Programmierung mit Java
3.11.4 Erstellung eines Pakets Es gibt außer den am Anfang erwähnten Regeln keine Bestimmung, die einschränkt, wie ein Entwickler seine Klassen und Schnittstellen zu Paketen zusammenfasst und gruppiert. Jede Java-Datei gehört ohne eine explizite Zuweisung zum Default-Paket. Dies nennt man das anonyme Paket. Wenn die package-Anweisung in einer Java-Datei fehlt, werden alle Klassen in der Datei diesem Paket ohne Namen zugeordnet. Eine Java-Datei wird einem anderen neuen Paket bzw. einem bestehenden Paket zugeordnet, wenn Sie ganz am Anfang der Datei als erste gültige Anweisung (auch vor der ersten Klassendefinition, aber abgesehen von Kommentaren) das Schlüsselwort package und den Namen des Pakets, gefolgt von einem Semikolon, setzen. Wenn der Compiler eine entsprechende Verzeichnisstruktur nicht vorfindet, werden die Verzeichnisse angelegt. Das Verfahren setzt sich mit eventuellen Unterverzeichnissen fort. Es kann nur eine solche Anweisung in einer .java-Datei notiert werden. Anschließend definieren Sie wie gewohnt Ihre Klassen.
Weiterentwicklung des Bauernhof-API-Projekts Strukturieren wir als Beispiel für den Umgang mit Paketen unser BauernhofAPI entsprechend um. Am besten erstellen Sie dazu einen neuen Projektordner. Wenn Sie von Hand mit den JDK-Tools arbeiten, kopieren Sie am einfachsten alle Quelldateien in dieses oberste Projektverzeichnis. Sowohl das API als auch das eigentliche Programm sollen in einer Paketstruktur innerhalb des Projektordners einsortiert werden, die de.rjs.bauernhof als oberste Ebene hat. Darin gibt es die direkten Unterpakete ebene1, ebene2 und ebene3. Diese sollen die Vererbungsebenen abbilden (siehe Abbildung 3.19).
Abb. 3.19: Die Verzeichnisstruktur für die Neustrukturierung des API
104
Pakete und die import-Anweisung
Beachten Sie, dass diese Abbildung bewusst nicht synchron mit den tatsächlichen Vererbungsbeziehungen ist. Das Verzeichnis ebene2 ist also kein Unterverzeichnis von ebene1. Eine Vererbungsbeziehung und eine Paketstruktur sind zwei vollkommen separate Ordnungsstrukturen, die man für verschiedene Zwecke einsetzen kann. Sie erlauben neben verschiedenen logischen Aufteilungen auch eine unterschiedliche Sicht auf eine Problemstellung. Das soll nicht bedeuten, dass diese zwingend asynchron gewählt werden müssen. Es ist eine Frage der logischen Strukturierung Ihres Projekts (in der OOD-Phase ist das festzulegen). Beachten Sie, dass durch die Paketstruktur beim Kompilieren und Ausführen des Programms ein paar Anpassungen notwendig sind. Sie können die Quelldateien mit javac -d . *.java kompilieren und dann mit java de.rjs.bauernhof.Bauernhof ausführen. Die Klasse Tier wird mit der Anweisung package de.rjs.bauernhof.ebene1;
(als erste Anweisung der Quelltextdatei Tier.java zu notieren) dem Unterverzeichnis de/rjs/bauernhof/ebene1 zugeordnet. In diesem Verzeichnis befindet sich nach der Kompilierung die Klassendatei. Sonst bleibt die Quelltextdatei unverändert. Die Datei Kuh.java wird am Anfang um folgende zwei Anweisungen erweitert: package de.rjs.bauernhof.ebene2; import de.rjs.bauernhof.ebene1.Tier;
Die erste Anweisung ordnet die Klasse Kuh dem Paket de.rjs.bauernhof. ebene2 zu (was eine Einsortierung in das Unterverzeichnis de/rjs/bauernhof/ ebene2 bedeutet). Da Kuh eine Subklasse von Tier ist, muss auf diese Klasse mit extends zugegriffen werden. Tier befindet sich nun aber nicht mehr im gleichen Paket. Also müssen Sie auf ein anderes Paket zugreifen. Das könnte vollqualifiziert mit extends de.rjs.bauernhof.ebene1.Tier erfolgen. Aber durch den Import am Anfang der Datei kann in der Folge auf Angaben verzichtet werden und der Rest der Datei Kuh.java bleibt gegenüber der ersten Variante unverändert. Analog gehen Sie bei den anderen Dateien vor, wobei die Dateien entsprechend der Vererbungstiefe in die Unterverzeichnisse ebene2 und ebene3 ein-
Objektorientierte Programmierung in Java
105
3 – Objektorientierte Programmierung mit Java
sortiert werden sollen. Dementsprechend wird die Klasse SchwSch wie folgt erweitert: package de.rjs.bauernhof.ebene2; import de.rjs.bauernhof.ebene1.Tier;
Die folgenden beiden Anweisungen werden in den Dateien Schaf.java und Schwein.java den bisherigen Anweisungen vorangestellt: package de.rjs.bauernhof.ebene3; import de.rjs.bauernhof.ebene2.SchwSch;
Die neue Strukturierung lässt sich selbstverständlich in einem UML-Klassendiagramm wiedergeben (Abbildung 3.20).
Abb. 3.20: Das UML-Klassendiagramm mit der Einsortierung der Klassen in verschiedene Pakete
106
Zugriffsschutz und Information Hiding in Java
Das eigentliche Programm Bauernhof.java soll direkt in das Paket de.rjs. bauernhof einsortiert werden. Das erzwingt als erste Anweisung in der Quelltextdatei package de.rjs.bauernhof;. Die nachfolgenden drei import-Anweisungen gestatten es, statt eines vollqualifizierten Zugriffs auf die Klassen Schaf, Kuh und Schwein aus dem Bauernhof-API die verkürzten Namen weiterzuverwenden und die Quelltextdatei bis auf die package- und die importAnweisungen unverändert zu lassen: import de.rjs.bauernhof.ebene2.Kuh; import de.rjs.bauernhof.ebene3.Schaf; import de.rjs.bauernhof.ebene3.Schwein;
Die import-Anweisungen können Sie nun auch verkürzen: import de.rjs.bauernhof.ebene2.*; import de.rjs.bauernhof.ebene3.*;
Durch den Stern werden alle Klassen in den Paketen importiert, so dass sie über verkürzte Bezeichner zugänglich sind. Die Änderungen in der Klasse Bauernhof waren nicht aufwändig, aber dennoch – die Umstrukturierung im API hatte Auswirkungen auf Klassen, die das API verwenden.
3.12 Zugriffsschutz und Information Hiding in Java Ihnen ist mittlerweile bekannt, dass man grundsätzlich in der OOP versucht, innere Strukturen von Objekten so weit wie möglich zu verbergen, um die Wiederverwendbarkeit möglichst hoch zu halten. Ohne einen vorangestellten Modifizierer ist eine Klasse bzw. ein Element der Klasse immer für alle anderen Klassen im gleichen Paket1 und mit dem Modifizierer public uneingeschränkt zugänglich. Der Modifizierer private beschränkt Strukturen in einer Klasse auf die ausschließliche Verwendung in dieser Klasse selbst. Beachten Sie die nachfolgende Klasse: A() { private int z = 1; } 1. Aber auch nicht weiter.
Objektorientierte Programmierung in Java
107
3 – Objektorientierte Programmierung mit Java
Die Variable z ist privat. Wenn Sie nun eine weitere Klasse im gleichen Verzeichnis erzeugen und daraus wie im folgenden Beispiel auf die private Variable z zugreifen wollen, wird der Compiler den Zugriff verhindern und eine entsprechende Fehlermeldung ausgeben. class B { public static void main(String[] args) { A ob1 = new A(); System.out.println(ob1.z); } }
3.12.1 Geschützter Zugriff Der Zugriffsschutz verhindert auch, dass Subklassen auf private Elemente zugreifen. Nun gibt es mit dem Modifizierer protected1 die Möglichkeit, eine Zugänglichkeit über Paketgrenzen hinweg zu gestatten, ohne die Klassenstrukturen uneingeschränkt freizugeben. Alle Elemente in einer Klasse mit dem Modifizierer protected sind für alle anderen Klassen des gleichen Pakets sowie alle Subklassen der aktuellen Klassen zugänglich. Aber nicht für Klassen aus anderen Paketen, die keine Subklassen darstellen.
Weiterentwicklung des Bauernhof-API-Projekts Nutzen wir nun protected, um das Bauernhof-API besser zu kapseln. Es ist wenig sinnvoll, wenn ein Anwender des Bauernhof-API direkt ein Objekt vom Typ Tier oder SchwSch erstellt und eine der dort bereitgestellten Eigenschaften verwendet. Die Klassen sind quasi Hilfsklassen und sollen ihre Eigenschaften nur Subklassen bereitstellen, die dann Objekte repräsentieren, die auch tatsächlich erstellt werden sollen2. Dementsprechend werden die Eigenschaften in Tier und SchwSch einfach auf protected gesetzt und gegen freie Verwendung geschützt. In den Subklassen stehen sie immer noch bereit – auch über Paketgrenzen hinweg.
1. Die Wahl des Bezeichners ist meines Erachtens sehr unglücklich, weil es sich dabei um eine Erweiterung der Zugänglichkeit gegenüber package handelt. 2. Wir werden später noch sehen, wie man verhindern kann, dass sich eine Klasse zum Erzeugen von Objekten verwenden lässt.
108
Zugriffsschutz und Information Hiding in Java
Wenn Sie das Projekt jetzt kompilieren wollen, erhalten Sie allerdings eine Fehlermeldung. Das API an sich wird kompiliert, aber nicht die Klasse Bauernhof. Die Eigenschaft alter wird in der Klasse Bauernhof nicht zugänglich sein. Warum? Bauernhof befindet sich in einem anderen Paket als Tier und ist keine Subklasse davon. Also wird alter zwar in Schwein verfügbar sein, aber von einem Objekt vom Typ Schwein nicht frei zur Verfügung gestellt. Um dieses Problem zu beheben, erweitern wir die Thematik.
3.12.2 Indirekte Zugriffe über Getter und Setter Aus verschiedenen Gründen werden viele Details in einem Objekt oft nicht direkt offen gelegt. Stattdessen realisiert man einen indirekten Zugang über Methoden, die den Zugriff gewährleisten. Das hat vielfältige Vorteile. Insbesondere kann man damit Werte filtern. Wenn Sie etwa ein Objekt haben, das eine Woche repräsentieren soll, macht es keinen Sinn, wenn Sie eine Variable für den Tag der Woche auf den Wert 8 setzen würden. Bei einem direkten Zugriff auf ein Feld wäre so ein Filter nur mühsam oder gar nicht zu realisieren. Wenn Sie jedoch über eine Methode zum Setzen der Tage auf das Feld zugreifen, kann im Inneren dieser Methode eine Überprüfung auf erlaubte Werte erfolgen. Das gilt für alle die Fälle, in denen nur bestimmte Werte sinnvoll sind. Aber auch bei der Abfrage des Werts von einem Feld kann so ein indirekter Zugriff sehr sinnvoll sein. Es kann sein, dass – je nach Situation – nur bestimmte Informationen sinnvoll sind. Ebenso können Sie durch die Unterbindung eines direkten Zugriffs auf ein Feld einrichten, dass ein Feld entweder nur zum Lesen oder nur zum Schreiben freigegeben ist. Je nach gewünschter Situation stellen Sie die entsprechenden Methoden bereit oder auch nicht. Ein indirekter Zugriff gestattet auch Änderungen von Werten, sowohl was den Namen als auch den Typ angeht. Die Filterfunktion sorgt dafür, dass die Schnittstelle nach außen unverändert bleibt.
Die tatsächliche Notation von Getter und Setter Grundsätzlich werden Sie diese Vorgehensweise in einem Java-API sehr, sehr oft realisiert finden. Die Methoden zum Setzen des Werts einer Eigenschaft beginnen dort durchgängig mit set und die Methoden zum Abfragen des Werts einer Eigenschaft mit get oder is (Letzteres bei boolean-Werten). Beispiele sind etwa setText("Text") zum Setzen der Beschriftung einer Schaltfläche
Objektorientierte Programmierung in Java
109
3 – Objektorientierte Programmierung mit Java
oder getText() zum Abfragen des Beschriftungstexts. Man nennt solche Methoden Getter-Methoden bzw. Setter-Methoden. Grundsätzlich sollte man in der OOP möglichst alle Eigenschaften einer Klasse nur indirekt zugänglich machen und dafür Lese- und Schreibmethoden zur Verfügung stellen, wenn es sinnvoll ist. Man spricht bei Objekten, die ihre innere Struktur vollkommen verbergen und nur über einen äußeren Ring mit Getter- und Setter-Methoden Zugang erlauben, von einem Donuts-Modell, denn das Modell wird in der Regel wie diese amerikanische Süßspeise dargestellt (siehe Abbildung 3.21).
Abb. 3.21: Im Donuts-Modell erfolgt der Zugang zum Inneren eines Objekts nur über einen äußeren Ring
Weiterentwicklung des Bauernhof-API-Projekts Entsprechend dem Donuts-Modell entwickeln wir das Bauernhof-API so weiter, dass sämtliche internen Felder nur noch indirekt zugänglich sind. Damit vollziehen wir eine Umstrukturierung des API, die nach außen massive Konsequenzen hat. Die Klasse Bauernhof verlässt sich zum Beispiel auf die bisherige Struktur. Wenn Sie nun alle Felder nur noch indirekt zugänglich machen, lässt sich die Klasse nicht mehr kompilieren. Sie muss angepasst werden. Ich möchte mit dieser folgenhaften Umstrukturierung zeigen, dass eine sorgfältige Analyse und ein ebenso sorgfältiges Design in der objektorientierten
110
Zugriffsschutz und Information Hiding in Java
Programmierung unabdingbar sind und spätere Umstrukturierungen massive Konsequenzen haben können. Sofern Sie jedoch von Anfang an sorgfältige Kapselung, Information Hiding und indirekten Zugriff implementieren, werden spätere Umstrukturierungen weniger bzw. gar keine Auswirkungen für einen Anwender eines API haben. Aber schauen wir uns das API an. In den Klassen Kuh, Schaf und Schwein werden alle dort neu eingeführten Felder auf private gesetzt. Die vererbten Eigenschaften sind als protected definiert und da die Klassen keine Subklassen haben und in den Paketen keine Anwendungsklassen notiert werden, ist das für einen Zugriffschutz ausreichend. Wir müssen allerdings für jede Eigenschaft eine Getter- und eine Setter-Methode implementieren. Die Klasse Kuh sieht jetzt also wie folgt aus: package de.rjs.bauernhof.ebene2; import de.rjs.bauernhof.ebene1.Tier; public class Kuh extends Tier { private int milch; public int getMilch() { return milch; } public void setMilch(int milch) { this.milch = milch; } public byte getAlter() { return alter; } public void setAlter(byte alter) { this.alter = alter; } public int getFutter() { return futter; } public void setFutter(int futter) { this.futter = futter; } }
Objektorientierte Programmierung in Java
111
3 – Objektorientierte Programmierung mit Java
Beachten Sie die Anwendung des Schlüsselworts this und die identisch benannten Bezeichner für die Instanz- und die lokalen Variablen. Analog wird die Klasse Schaf angepasst: package de.rjs.bauernhof.ebene3; import de.rjs.bauernhof.ebene2.SchwSch; public class Schaf extends SchwSch { private int wolle; public int getWolle() { return wolle; } public void setWolle(int wolle) { this.wolle = wolle; } public byte getAlter() { return alter; } public void setAlter(byte alter) { this.alter = alter; } public int getFutter() { return futter; } public void setFutter(int futter) { this.futter = futter; } public int getGewicht() { return gewicht; } public void setGewicht(int gewicht) { this.gewicht = gewicht; } }
112
Zugriffsschutz und Information Hiding in Java
Und auch die Klasse Schwein hat die gleiche Struktur: package de.rjs.bauernhof.ebene3; import de.rjs.bauernhof.ebene2.SchwSch; public class Schwein extends SchwSch { private boolean geschlecht; public boolean isGeschlecht() { return geschlecht; } public void setGeschlecht(boolean geschlecht) { this.geschlecht = geschlecht; } public byte getAlter() { return alter; } public void setAlter(byte alter) { this.alter = alter; } public int getFutter() { return futter; } public void setFutter(int futter) { this.futter = futter; } public int getGewicht() { return gewicht; } public void setGewicht(int gewicht) { this.gewicht = gewicht; } }
Beachten Sie, dass die mehrfache Notation einiger Getter- und Setter-Methoden in den drei Klassen im Allgemeinen nicht sinnvoll ist. Besser wäre eine Verlagerung in eine Superklasse, aber das soll an dieser Stelle noch nicht geschehen. Wir werden im übernächsten Kapitel weitere OO-Techniken sehen, die so eine Verlagerung jedoch sehr einfach möglich machen.
Objektorientierte Programmierung in Java
113
3 – Objektorientierte Programmierung mit Java
Wenn Sie sich das UML-Klassendiagramm des veränderten APIs ansehen, werden Sie nun in den Klassen die veränderten Sichtbarkeiten und die jeweiligen Setter- und Getter-Methoden wiederfinden (Abbildung 3.22).
Abb. 3.22: Das UML-Klassendiagramm für den aktuellen Stand des Bauernhof-API
Die Umstrukturierung des Bauernhof-API macht jetzt natürlich massive Anpassungen in der Klasse Bauernhof.java erforderlich. Diese sieht nun wie folgt aus, wenn sie die gleiche Funktionalität wie vorher liefern soll:
114
Zusammenfassung package de.rjs.bauernhof; import de.rjs.bauernhof.ebene2.Kuh; import de.rjs.bauernhof.ebene3.Schaf; import de.rjs.bauernhof.ebene3.Schwein; public class Bauernhof { public static void main(String[] args) { Kuh helga = new Kuh(); Schaf manfred = new Schaf(); Schwein babe = new Schwein(); helga.setMilch(40); manfred.setWolle(13); babe.setGeschlecht(false); babe.setAlter((byte)5); System.out.println("Die Kuh Helga gibt " + helga.getMilch() + " Liter Milch am Tag."); System.out.println("Mani liefert " + manfred.getWolle() + " Kilo Wolle."); System.out.println("Ist Babe ein Eber? " + babe.isGeschlecht()); System.out.println("Babe ist " + babe.getAlter() + " Jahre alt."); } }
Dort, wo bisher ein direkter Zugriff auf Eigenschaften erfolgte, wird nun über Getter- und Setter-Methoden auf die Werte der Eigenschaften zugegriffen.
3.13 Zusammenfassung In diesem Kapitel haben Sie bedeutende Details zu Java und seiner Historie erfahren und die wichtigsten objektorientierten Konzepte kennen gelernt. Sie kennen nun elementare Syntaxregeln zum Umsetzen der objektorientierten Philosophie in Java. Die Themen dieses Kapitels waren der Umgang mit Klassen und Objekten unter Java, das Speichermanagement einschließlich Konstruktoren und (nicht explizit aufzurufenden) Destruktoren, Klassen- und Instanzelemente, Namensregeln und Namensräume, die Realisierung von Methoden und Variablen in Java, die Vererbung, Pakete und der Zugriffschutz einschließlich Getter und Setter.
Objektorientierte Programmierung in Java
115
4
Grundlegende Sprachelemente von Java
Dieser Abschnitt des Buchs behandelt die zentralen Syntaxstrukturen von Java. Hier wird detailliert auf die Grundlagen der Sprache Java und die entsprechenden Syntaxdetails wie Literale, Variablen, Datentypen, Operatoren, Kontrollstrukturen und Datenfelder eingegangen. Die objektorientierten Abschnitte in den Kapiteln zuvor bilden die Grundlage, um überhaupt Java-Programme schreiben zu können. Das vorliegende Kapitel baut nun auf diesem Fundament auf und zeigt Ihnen Techniken, mit denen man erst richtig sinnvolle Java-Programme schreiben kann. Beide Teile gehören zur Pflicht. Allgemein werden die Ausführungen in diesem Kapitel aufgrund der Zugehörigkeit von Java zur C-Sprachfamilie sehr große Übereinstimmungen mit anderen Sprachen dieser Familie aufweisen. Wir halten daher den Abschnitt aus Platzgründen eher kompakt. Insbesondere erheben die Ausführungen keinen Anspruch auf Vollständigkeit.
4.1 Token Token bedeutet übersetzt Zeichen oder Merkmal. Wenn ein Compiler Quelltext übersetzt, muss er zunächst herausfinden, welche Token oder Symbole im Code dargestellt sind. Ein Token kann man auch als Sinnzusammenhang verstehen. So ist etwa in der menschlichen Sprache ein Wort nicht nur die Summe seiner Buchstaben oder Zeichen, sondern es besitzt einen konkreten Sinnzusammenhang, den der menschliche Geist mit einer bestimmten Bedeutung assoziiert. Allerdings muss ein Mensch auch eine Sprache verstehen, sonst bleibt ein Token einfach nur die Summe seiner Buchstaben oder Zeichen. Wenn der Java-Compiler den Quellcode kompiliert, zerlegt er ihn dabei in einzelne kleine Bestandteile (Token). Der gesamte Quelltext muss sich dabei in logisch sinnvolle Einheiten zerlegen und in gültige Arten von Token einordnen lassen. Leerzeichen und Kommentare werden aus dem Text entfernt und die Sprachelemente auf ihre Richtigkeit geprüft. Alle Operationen mit einem Token werden mit dem spezifischen Wert durchgeführt, also nicht mit den Buchstaben des Tokens selbst, sondern mit dem, was der Compiler mit dem Token assoziiert. Der gesamte Vorgang der Zerlegung in gültige Java-Token wird bei
Objektorientierte Programmierung in Java
117
4 – Grundlegende Sprachelemente von Java
der Übersetzung eines Quelltexts in den Bytecode vom so genannten Parser übernommen, der bereits eine erste Kontrolleinheit des Sicherheits- und Stabilitätskonzepts von Java darstellt.
4.1.1
Token-Typen
Es gibt in Java wie in den meisten Computersprachen fünf Arten von Token:
쐌 Bezeichner oder Identifier (diese haben wir schon ausführlich behandelt) 쐌 Schlüsselwörter 쐌 Literale 쐌 Operatoren 쐌 Trennzeichen Kommentare oder Leerräume (Leerzeichen, Tabulatoren und Zeilenvorschübe) bilden streng genommen keine Token (wie man beispielsweise daran erkennen kann, dass sie vom Compiler entfernt werden). Sie passen dennoch in das Gesamtkonzept, weil sie auf Quelltextebene wie Token notiert werden. Schlüsselwörter Schlüsselwörter umfassen alle Bezeichner, die einen essenziellen Teil der Java-Sprachdefinition darstellen und die in Java eine besondere Bedeutung haben. Die nachfolgende alphabetische Tabelle gibt alle Java-Schlüsselwörter an, wobei einige Token nur reserviert sind, ohne dass sie bereits in Java verwendet werden. abstract
assert
boolean
byvalue
case
cast
catch
char
class
const
continue
default
do
double
else
enum
extends
false
final
finally
float
for
future
generic
goto
if
implements
import
inner
instanceof
int
interface
long
native
new
null
operator
outer
Tabelle 4.1: Die Java-Schlüsselwörter
118
break
byte
Token package
private
protected
public
rest
return
short
static
strictf
super
switch
synchronized
this
throw
throws
transient
true
try
var
void
volatile
while
Tabelle 4.1: Die Java-Schlüsselwörter (Forts.)
Literale Ein Literal ist das, was in einem Quelltext selbst einen Wert repräsentiert. Im einfachen Fall handelt es sich um eine Zahl oder ein Zeichen, aber auch um einen Text oder einen booleschen Wert. Literale sind für den Compiler spezielle Token, die zu speichernde Werte in Form eines Datentyps darstellen.
Zeichenkettenliterale – Objekte vom Typ String Zeichenkettenliterale sind aus mehreren Zeichenliteralen zusammengesetzte Ketten, die allgemein als Strings bezeichnet werden. Bei Zeichenkettenliteralen werden null oder mehr Zeichen in doppelten Anführungszeichen dargestellt1. Java erzeugt Zeichenketten als Instanzen der Klasse String. Es sind also Objekte oder genauer Referenztypen – mit allen damit verbundenen Vorteilen. So können Sie in Java nicht über das Ende eines Strings hinausgreifen und damit Programmfehler auslösen. Es stehen alle Eigenschaften und vor allem Methoden der Klasse String zur Manipulation einer Zeichenkette zur Verfügung. Diese Methoden können zum Vergleichen oder Durchsuchen von Zeichenketten verwendet werden, oder sie dienen zum Extrahieren von einzelnen Zeichen. Wichtige Methoden sind equals(), die Zeichenketten auf Gleichheit überprüft, oder length(), die die Länge eines Strings zurückgibt. Zeichenkettenliterale können Steuerzeichen wie Tabulatoren, Zeilenvorschübe, nichtdruckbare Unicode-Zeichen oder druckbare Unicode-Spezialzeichen in Form von Zeichenliteralkodierungen enthalten.
1. Beide Anführungszeichen eines Zeichenkettenliterals müssen in derselben Zeile des Quellcodes stehen! Mit anderen Worten – Strings dürfen nicht über mehrere Zeilen verteilt werden.
Objektorientierte Programmierung in Java
119
4 – Grundlegende Sprachelemente von Java
Trennzeichen und Leerräume Unter Trennzeichen versteht man alle einzelnen Symbole, die zum Trennen von Token oder zum Anzeigen von Zusammenfassungen von Code benutzt werden. Java kennt neun Trennzeichen: Token
Beschreibung
(
Der Token wird sowohl zum Öffnen einer Parameterliste für eine Methode als auch zur Festlegung eines Vorrangs für Operationen in einem Ausdruck benutzt.
)
Der Token wird sowohl zum Schließen einer mit ( geöffneten Parameterliste für eine Methode als auch zur Beendigung eines mit ( festgelegten Vorrangs für Operationen in einem Ausdruck benutzt.
{
Der Token wird zu Beginn eines Blocks mit Anweisungen oder einer Initialisierungsliste gesetzt.
}
Der Token wird an das Ende eines mit dem Token { geöffneten Blocks mit Anweisungen oder einer Initialisierungsliste gesetzt und schließt den Block wieder.
[
Der Token steht vor einem Ausdruck, der als Index für ein Datenfeld dient.
]
Der Token folgt einem Ausdruck, der als Index für ein Datenfeld dient, und beschließt den Index.
;
Das Semikolon dient sowohl zum Beenden einer Ausdrucksanweisung als auch zum Trennen der Teile bei einer for-Anweisung.
,
Der Token „Komma“ ist multifunktional und wird in vielen Zusammenhängen als Begrenzer verwendet.
.
Der Punkt wird zum einen als Dezimalpunkt, zum anderen als Trennzeichen von Paketnamen, Klassennamen oder Methodenund Variablennamen benutzt.
Tabelle 4.2: Die Java-Trennzeichen
Leerräume (white space) sind verwandt mit Trennzeichen. Es handelt sich um alle Zeichen, die in beliebiger Anzahl und an jedem Ort zwischen allen Token platziert werden können und keinerlei andere Bedeutung haben, als Tokens zu trennen und/oder den Quellcode übersichtlich zu gestalten.
120
Token
Operatoren Operatoren sind Zeichen oder Zeichenkombinationen, die eine auszuführende Operation mit einem oder mehreren Operanden durchführen. Sie sind der Schlüssel für jegliche Art von halbwegs aufwändigen Programmstrukturen. In Java (wie auch den meisten anderen Programmiersprachen) werden die Operatoren in mehrere Kategorien eingeteilt.
Arithmetische Operatoren Arithmetische Operatoren werden für mathematische Berechnungen verwendet und benutzen ein oder zwei Operanden. Java kennt die folgenden arithmetischen Operatoren: Operator
Bedeutung
-
Der Subtraktionsoperator bzw. die einstellige arithmetische Negierung
+
Der Additionsoperator bzw. das Gegenteil der arithmetischen Negierung (nur aus Symmetriegründen vorhanden)
*
Der Multiplikationsoperator
/
Der Divisionsoperator
%
Der Modulooperator zur Rückgabe des Rests einer Division. In Java ist der Modulooperator nicht nur für Ganzzahlen definiert (wie etwa in C/C++ oder den meisten anderen Techniken), sondern auch für Fließkommazahlen! Es ist einfach die natürliche Fortsetzung der Operation auf die Menge der Fließkommazahlen.
++
Der einstellige Inkrementoperator zum Erhöhen des Werts des Operanden um 1. Die Reihenfolge von Operand und Operator ist wichtig. Wenn der Operator vor dem Operanden steht, erfolgt die Erhöhung des Werts, bevor (!) der Wert dem Operanden zugewiesen wird. Steht er hinter dem Operanden, erfolgt die Erhöhung, nachdem (!) der Wert bereits zugewiesen wurde.
Tabelle 4.3: Die arithmetischen Java-Operatoren
Objektorientierte Programmierung in Java
121
4 – Grundlegende Sprachelemente von Java Operator
Bedeutung
--
Der einstellige Dekrementoperator erniedrigt den Wert des Operanden um 1. Die Reihenfolge von Operand und Operator ist auch hier von Bedeutung. Wenn der Operator vor dem Operanden steht, erfolgt das Senken des Werts, bevor der Wert dem Operanden zugewiesen wird. Wenn er hinter dem Operanden steht, erfolgt das Senken, nachdem der Wert bereits zugewiesen wurde.
Tabelle 4.3: Die arithmetischen Java-Operatoren (Forts.)
Zuweisungsoperatoren Zuweisungsoperatoren werden – wie der Name schon sagt – für die Zuweisung eines Werts zu einer Variablen verwendet. In Java gibt es neben dem direkten Zuweisungsoperator die arithmetischen Zuweisungsoperatoren. Diese sind als Abkürzung für Kombinationen aus arithmetischer Operation und Zuweisung zu verstehen. Operator
Bedeutung
Beispiel
Entspricht
+=
Additionszuweisungsoperator
x += 3
x=x+3
-=
Subtraktionszuweisungsoperator
x –= 7
x=x–7
*=
Multiplikationszuweisungsoperator
x *= 11
x = x * 11
/=
Divisionszuweisungsoperator
x /= 2
x=x/2
%=
Modulozuweisungsoperator
x %= 99
x = x % 99
=
direkter Zuweisungsoperator
x = 42
Tabelle 4.4: Der einfache und die arithmetischen Zuweisungsoperatoren
Vergleichsoperatoren Vergleichsoperatoren werden meist in Schleifenbedingungen verwendet, die auf einen booleschen Wert prüfen. Sie haben zwei Operanden gleichen Typs und vergleichen diese. Als Rückgabewert der Operation entsteht immer (!) ein boolescher Wert (true oder false). Es ist in Java nicht möglich, einen numerischen Rückgabewert zu erhalten (wie etwa in C/C++, wo man auf Gleichheit mit 0 oder Ungleichheit testen kann).
122
Token Operator
Beschreibung
==
Gleichheitsoperator
!=
Ungleichheitsoperator
<
Kleiner-als-Operator
>
Größer-als-Operator
<=
Kleiner-als-oder-gleich-Operator
>=
Größer-als-oder-gleich-Operator
Tabelle 4.5: Vergleichsoperatoren
Referenzvergleiche und equals() Interessant ist die Anwendung von Vergleichsoperatoren, wenn die Operanden Objekte (die ja Referenzen darstellen) sind. In diesem Fall muss man massiv zwischen den Objekten und den darin gespeicherten Werten unterscheiden. Zwar bedeutet eine Objektgleichheit immer die Gleichheit der Werte. Das ist trivial, denn in dem Fall liegt die Referenz auf den gleichen Speicherplatz vor. Jedoch gilt nicht der Umkehrschluss. Zwei verschiedene Objekte können selbstverständlich gleiche Werte beinhalten. Der Vergleich mittels der Vergleichsoperatoren liefert dann Ungleichheit, obwohl der Inhalt unter Umständen gleich ist. Die Vergleichsoperatoren prüfen nur darauf, ob die Referenzen auf die Objekte gleich sind. Um die Gleichheit des Inhalts von Referenzen zu überprüfen, gibt es die in allen Objekten verfügbare Methode equals() (vererbt von Object). Ziehen wir zur Verdeutlichung das folgende Beispiel heran, in dem Vergleiche zwischen zwei Integer-Objekten durchgeführt werden. class A { public static void main(String[] args) { Integer a = new Integer(2); Integer b = a; Integer c = new Integer(2); System.out.println( "Sind a und b das gleiche Objekt? " + (a==b)); System.out.println( "Haben a und b den gleichen Inhalt? " + a.equals(b));
Objektorientierte Programmierung in Java
123
4 – Grundlegende Sprachelemente von Java System.out.println( "Sind a und c das gleiche Objekt? " System.out.println( "Haben a und c den gleichen Inhalt? a.equals(c)); System.out.println( "Sind b und c das gleiche Objekt? " System.out.println( "Haben b und c den gleichen Inhalt? b.equals(c));
+ (a==c)); " +
+ (b==c)); " +
} }
Das Programm arbeitet mit drei Variablen, die jeweils ein Integer-Objekt1 aufnehmen. Die Objekte a und b sind dabei explizit identisch, denn b bekommt mit dem Zuweisungsoperator das Objekt a zugewiesen. Damit ist b nichts anderes als ein Pointer auf den gleichen Speicherbereich, auf den auch a zeigt. Das heißt, die Objekte müssen identisch sein. Das Objekt c wird neu erzeugt. Zwar ist der Inhalt identisch (wie bei a und b der numerische Wert 2), aber es ist damit explizit ein anderes Objekt. Es wird in einem anderen Speicherbereich erzeugt. Schauen Sie sich die Ausgabe des Programms zur Verdeutlichung an: Haben a und b den gleichen Inhalt? true Sind a und c das gleiche Objekt? false Haben a und c den gleichen Inhalt? true Sind b und c das gleiche Objekt? false Haben b und c den gleichen Inhalt? true
String-Vergleiche als besondere Referenzvergleiche Da Strings in Java Objekte bzw. Referenztypen sind, sollte entsprechend auch der Test auf Übereinstimmung der Werte von Strings nicht als Referenzvergleich durchgeführt werden. Und das bedeutet, Sie vergleichen die Inhalte von zwei Strings stattdessen mit der equals()-Methode. Aber schauen Sie sich das nachfolgende Listing an:
1. Integer ist eine so genannte Wrapper-Klasse, auf die wir in diesem Kapitel eingehen.
124
Token public class B { public static void main(String[] args) { String a = "abc"; String b = "abc"; System.out.println( "Haben a und b den gleichen Inhalt? " + a.equals(b)); System.out.println( "Sind a und b das gleiche Objekt? " + (a==b)); } }
Das Beispiel ist dem vorherigen Beispiel sehr ähnlich. Es werden nur zwei Objekte (vom Typ String) angelegt und beiden wird der gleiche Wert zugewiesen. Wie wird die Ausgabe des Programms aussehen? Haben a und b den gleichen Inhalt? true Sind a und b das gleiche Objekt? true
Natürlich haben a und b den gleichen Inhalt. Aber wie erklären Sie die zweite Ausgabe? Wenn Sie Referenzvergleiche richtig verstanden haben, sollten Sie jetzt vom Glauben abfallen ;-). Warum liefert a == b den Wert true? Nun, a und b verweisen auf den gleichen Speicherbereich. Der Grund ist die Arbeit des Compilers. Es handelt sich um einen optimierenden Compiler. Im Fall von Strings erkennt der Compiler, dass zwei Variablen der gleiche Inhalt zugewiesen wurde. Statt diesen identischen Text zweimal im Hauptspeicher zu halten, bekommt die zweite Variable im Hintergrund statt eines eigenen Zeichenkettenliterals einfach eine Referenz auf den bereits vom ersten Objekt referenzierten Text zugewiesen. Dies kann erheblich Ressourcen sparen1. Erst wenn einer der beiden Variablen ein anderer Inhalt zugewiesen wird, werden die beiden Variablen auch auf getrennte Speicherbereiche verweisen (was vollkommen verborgen im Hintergrund abläuft). Sie sollten aus diesem Verhalten jetzt aber nicht den Schluss ziehen, dass Sie String-Vergleiche doch mit dem Operator == durchführen könnten. Es gibt genug Situationen, in denen der Compiler nicht optimiert. Etwa dann, wenn Anwendereingaben entgegenzunehmen sind. Diese können ja trivialerweise
1. Eine Referenz ist in Java ja immer nur vier Byte groß.
Objektorientierte Programmierung in Java
125
4 – Grundlegende Sprachelemente von Java
zum Kompilierzeitpunkt nicht bekannt sein und der Compiler muss für zwei String-Objekte unterschiedliche Speicherbereiche vorsehen. Oder aber, wenn zwei Strings auf die klassische Weise erzeugt werden. Sie fragen sich bestimmt, was aber nun schon wieder „klassische Weise“ bedeuten soll? Gegenfrage: Wie erzeugen Sie Objekte? Antwort: mit dem Konstruktor der Klasse, aus der Sie ein Objekt erzeugen wollen. Sie wollen ein Objekt vom Typ String erzeugen? Also nehmen Sie den Konstruktor. Beispiel: String a = new String("abc"); String b = new String("abc");
Diese „klassische“ Art der Objekterzeugung funktioniert bei (fast1) allen Objekten und selbstverständlich auch bei Strings. Und so erzeugen Sie auf jeden Fall zwei getrennte Objekte und der Vergleich a == b liefert dann false, während a.equals(b) den Wert true zurückgibt. Die Anwendung der equals()Methode ist also in jedem Fall sicherer, wenn Sie wirklich nur Textinhalte vergleichen wollen.
Strings als Sündenfall von Java Viele OO-Puristen behaupten, es gäbe bei Java drei Sündenfälle im Vergleich zur reinen OO-Lehre. Die Implementierung von Strings in Java ist der eine Sündenfall2. Und zwar nicht deshalb, weil so etwas wie String a = new String("abc"); zur Erzeugung eines String-Objekts gemacht werden kann, sondern weil String a = "abc"; funktioniert. Dies weicht ja explizit die strenge Vorgehensweise zum Erzeugen von Objekten auf. Aber Sun hat wahrscheinlich sehr gut daran getan, die direkte Wertzuweisung von Strings zu einer Variablen vom Typ String (mit impliziter Objekterzeugung) nicht zu unterbinden. Die Akzeptanz von Java wäre sonst bei vielen Programmierern der alten Schule erheblich schlechter ausgefallen.
Logische Operatoren Die logischen Operatoren sagen aus, ob zwei Werte gleich sind oder nicht. Die logischen Vergleichsoperatoren können nur mit booleschen Operanden 1. Arrays müssen gesondert behandelt werden – dazu kommen wir noch. 2. Sündenfall zwei und drei sind die Implementierung von Arrays und die Existenz von primitiven Datentypen.
126
Token
verwendet werden. Dabei stellt Java – im Gegensatz zu vielen anderen Programmiersprachen – die UND- bzw. ODER-Verknüpfung in zwei verschiedenen Varianten zur Verfügung. Es gibt einmal die so genannte Short-CircuitEvaluation, zum anderen die Bewertung ohne diese Technik. Bei der Short-Circuit-Evaluation eines logischen Ausdrucks wird von links nach rechts ausgewertet und eine Bewertung abgebrochen, wenn bereits ein ausgewerteter Teilausdruck die Erfüllung des gesamten Ausdrucks unmöglich macht. Mit anderen Worten: Eine Bewertung wird abgebrochen, wenn die weitere Auswertung eines Ausdrucks keine Rolle mehr spielt. Damit kann beispielsweise bei umfangreicheren Konstrukten eine Steigerung der Performance erreicht werden. Operator
Beschreibung
Bedeutung
&&
Logischer ANDOperator mit ShortCircuit-Evaluation
Die Operation x && y liefert true, wenn sowohl x als auch y true sind. Ist bereits x false, wird y nicht mehr bewertet.
||
Logischer OROperator mit ShortCircuit-Evaluation
Die Operation x || y liefert true, wenn mindestens einer der beiden Operanden true ist. Ist bereits x true, wird y nicht mehr bewertet.
!
Logischer NOTOperator
Vorangestellter Operator mit einem Operanden; Umdrehung des Wahrheitswerts
&
Logischer ANDOperator ohne ShortCircuit-Evaluation
Die Operation x & y liefert true, wenn sowohl x als auch y true sind. Beide Operanden werden bewertet.
|
Logischer OROperator ohne ShortCircuit-Evaluation
Die Operation x | y liefert true, wenn mindestens einer der beiden Operanden true ist. Beide Operanden werden bewertet.
^
EXKLUSIV-ODER
Die Operation x ^ y liefert true, wenn beide Operanden verschiedene Wahrheitswerte haben.
Tabelle 4.6: Die logischen Vergleichsoperatoren
Objektorientierte Programmierung in Java
127
4 – Grundlegende Sprachelemente von Java
Der ternäre Operator Es gibt in Java einen Operator mit drei Operanden – den Bedingungsoperator zur Abkürzung der if-Anweisung. Er wird als if-else-Operator bzw. ternärer oder triadischer Operator bezeichnet und stellt eine Abkürzung für die ausgeschriebene if-else-Anweisung dar. Der Operator besteht aus folgendem Konstrukt: [bedingung] ? [erg1] : [erg2];
Der Operator liefert den Wert [erg1] zurück, wenn die Bedingung (der boolesche Ausdruck vor dem Fragezeichen) true ist, ansonsten wird der Wert [erg2] zurückgegeben.
Der Casting-Operator Im Rahmen der Typumwandlung von primitiven Datentypen und Objekten gibt es einen so genannten Casting- oder Type-Cast-Operator. Er sieht immer so aus, dass in runden Klammern der Zieltyp der Umwandlung steht und er vor seinem Operanden notiert wird. Etwa so: (char) a; (int) b;
Wir werden beim Abschnitt zur Typkonvertierung genauer darauf eingehen.
Der String-Verkettungsoperator Wenn der Operator + auf mindestens zwei String-Operanden angewandt wird, verkettet er die beiden Operanden so, dass ein neuer String herauskommt. In diesem wird der Wert beider Operanden als Verkettung der Zeichen auftauchen.
Der instanceof-Operator Der instanceof-Operator ist ein Operator mit zwei Operanden: einem Objekt auf der linken und einem Klassennamen auf der rechten Seite. Wenn das Objekt auf der linken Seite des Operators tatsächlich vom Typ der Klasse auf der rechten Seite des Operators ist (oder vom Typ einer ihrer Subklassen), dann gibt dieser Operator den booleschen Wert true zurück. Ansonsten wird false zurückgegeben.
128
Token
Bitweise Operatoren Java verfügt auch über Operatoren auf Binärebene. Da solche Operationen aber hauptsächlich für die direkte Kommunikation mit Hardwarekomponenten verwendet werden und Java explizit die Hardware durch die JVM abschirmt, haben die Operatoren in Java nur eingeschränkte Bedeutung. Aus Platzgründen sei dafür auf weiterführende Literatur verwiesen.
Die Operatorenpriorität Wie jede Programmiersprache muss auch Java Operatoren nach Prioritäten gewichten. In der folgenden Tabelle werden sämtliche Operatoren von Java aufgelistet, wobei der Operator mit höchstem Vorrang ganz oben steht. Operatoren in der gleichen Zeile haben gleiche Priorität. Sämtliche Java-Operatoren bewerten mit Ausnahme der einstelligen Operatoren von links nach rechts. Beschreibung
Operatoren
Hochvorrangig
. [] ()
Einstellig
+ – ~ ! ++ -- (type)
Multiplikativ
*/%
Additiv
+-
Binäre Verschiebung
<< >> >>>
Relational
< <= >= > instanceof
Gleichheit
== !=
Bitweises And
&
Bitweises Xor
^
Bitweises Or
|
Short-turn And
&&
Short-turn Or
||
Bedingung
?:
Zuweisung
= und alle Zuweisungsoperatoren mit verbundener Operation
Tabelle 4.7: Die Java-Operatoren nach Priorität geordnet
Objektorientierte Programmierung in Java
129
4 – Grundlegende Sprachelemente von Java
4.2 Datentypen und Typumwandlungen Ein Typ bzw. Datentyp gibt in einer Computersprache an, wie ein einfaches Objekt (wie zum Beispiel eine Variable) im Speicher des Computers dargestellt wird – also wie viel Platz notwendig ist und welche Arten von Werten in diesem Speicherbereich abgelegt werden können. Er enthält ebenfalls Hinweise darüber, welche Operationen mit und an ihm ausgeführt werden können. Viele Computersprachen lassen es beispielsweise nicht zu, dass mit einer alphanumerischen Zeichenfolge direkte arithmetische Operationen durchgeführt werden, außer es besteht eine explizite Anweisung, diese Zeichenfolge zuerst in eine Zahl umzuwandeln.
4.2.1
Primitive Datentypen
Java besitzt acht primitive Datentypen, die explizit plattformunabhängig sind und – mit Ausnahme der Situation bei lokalen Variablen – immer einen wohldefinierten Vorgabewert haben. Typ
Länge
Default
Kurzbeschreibung
byte
8 Bits
0
Kleinster Wertebereich mit Vorzeichen zur Darstellung von Ganzzahlwerten (ganzzahliges Zweierkomplement) von (–2 hoch 7 = –128) bis (+2 hoch 7 – 1 = 127).
short
16 Bits
0
Kurze Darstellung von Ganzzahlwerten mit Vorzeichen als ganzzahliges Zweierkomplement von (–2 hoch 15 = –32.768) bis (+2 hoch 15 – 1 = 32.767).
int
32 Bits
0
Standardwertebereich mit Vorzeichen zur Darstellung von Ganzzahlwerten (ganzzahliges Zweierkomplement). Bereich von (–2 hoch 31 = –2.147.483.648) bis (+2 hoch 31 – 1 = 2.147.483.647).
Tabelle 4.8: Primitive Datentypen der Java-Sprache
130
Datentypen und Typumwandlungen Typ
Länge
Default
Kurzbeschreibung
long
64 Bits
0
Größter Wertebereich mit Vorzeichen zur Darstellung von Ganzzahlwerten (ganzzahliges Zweierkomplement). Wertebereich von –9.223.372.036.854.775.808 (–2 hoch 63) bis 9.223.372.036.854.775.807 (+2 hoch 63 – 1).
float
32 Bits
0.0
Kurzer Wertebereich mit Vorzeichen zur Darstellung von Gleitkommazahlwerten. Dies entspricht Fließkommazahlen mit einfacher Genauigkeit, die den IEEE-7541985-Standard benutzen. Der Wertebereich liegt ungefähr zwischen +/– 3,4E+38. Es existiert ein Literal zur Darstellung von plus/minus unendlich sowie der Wert NaN (Not a Number) zur Darstellung von nicht definierten Ergebnissen.
double
64 Bits
0.0
Großer Wertebereich mit Vorzeichen zur Darstellung von Gleitkommazahlwerten. Der Wertebereich liegt ungefähr zwischen +/– 1,8E+308. Auch diese Fließkommazahlen benutzen den IEEE-754-1985-Standard. Es existiert ein Literal zur Darstellung von plus/minus unendlich (Infinity), sowie der Wert NaN (Not a Number) zur Darstellung von nicht definierten Ergebnissen.
char
16 Bits
\u0000
Darstellung eines Zeichens des UnicodeZeichensatzes. Zur Darstellung von alphanumerischen Zeichen wird dieselbe Kodierung wie beim ASCII-Zeichensatz verwendet, aber das höchste Byte ist auf 0 gesetzt. Der Datentyp ist als einziger primitiver Java-Datentyp vorzeichenlos! Der Maximalwert, den char annehmen kann, ist \uFFFF.
Tabelle 4.8: Primitive Datentypen der Java-Sprache (Forts.)
Objektorientierte Programmierung in Java
131
4 – Grundlegende Sprachelemente von Java Typ
Länge
boolean 1 Bit
Default
Kurzbeschreibung
false
Werte dieses Typs können true (wahr) oder false (falsch) sein. Alle logischen Vergleiche in Java liefern den Typ boolean. Werte vom Typ boolean sind zu allen anderen primitiven Datentypen inkompatibel und lassen sich nicht in andere Typen überführen.
Tabelle 4.8: Primitive Datentypen der Java-Sprache (Forts.)
Ein Literal besitzt – sozusagen von Natur aus – einen Datentyp. Ganzzahlliterale haben immer int und Gleitkommaliterale double als Typ. Mit einem nachgestellten l oder L kann aus einem Ganzzahlliteral ein long-Datentyp und mit einem nachgestellten f oder F aus einem Gleitkommaliteral ein float gemacht werden. Beispiele: 4L 3.12f
Variablen erhalten den Datentyp bei der Deklaration, indem er einfach dem Bezeichner vorangestellt wird. Beispiele: int a; double b;
4.2.2
Typkonvertierungen
Unter Casting bzw. Typkonvertierung versteht man die Umwandlung eines Datentyps in einen anderen Datentyp. Java ist eine streng typisierte Sprache, weil sehr intensive Typüberprüfungen stattfinden – an verschiedenen Stellen des gesamten Prozesses, sowohl bereits bei der Kompilierung als auch zur Laufzeit. Außerdem gelten strikte Beschränkungen für die Umwandlung (Konvertierung) von Werten eines Datentyps zu einem anderen. Java unterstützt zwei unterschiedliche Arten von Konvertierungen:
쐌 Explizite Konvertierungen, um absichtlich den Datentyp eines Werts zu verändern 쐌 Ad-hoc-Konvertierungen ohne Zutun des Programmierers
132
Datentypen und Typumwandlungen
Ad-hoc-Typkonvertierung Java führt bei der Auswertung von Ausdrücken einige Typkonvertierungen ad hoc durch. Das muss ein Programmierer gar nicht bewusst wahrnehmen. Insbesondere wird er nicht ausdrücklich aktiv. Eine Umwandlung erfolgt dann, wenn die Situation es erfordert. Eine solche Situation tritt beispielsweise ein, wenn bei einer Zuweisung der Typ der Variablen und der Typ des zugewiesenen Ausdrucks nicht übereinstimmen, der Wertebereich der Zuweisung eines Ausdrucks nicht ausreicht, verschiedene Datentypen in einem Ausdruck verknüpft werden oder die an einen Methodenaufruf übergebenen Parameter vom Datentyp nicht mit den geforderten Datentypen übereinstimmen. Die Regeln für eine automatische Konvertierung sind für numerische Datentypen untereinander allerdings sehr einfach.
쐌 Wenn nur Ganzzahltypen miteinander kombiniert werden, legt der größte Datentyp den Ergebnisdatentyp fest. Wenn das Ergebnis einer Verknüpfung vom Wert her so groß ist, dass es im so vorgesehenen Wertebereich nicht mehr dargestellt werden kann, wird der nächstgrößere Datentyp genommen. Beachten Sie, dass ganzzahlige Literale vom Typ int sind. 쐌 Bei Operationen mit Fließkommazahlen gelten weitgehend die analogen Regeln. Wenn mindestens einer der Operanden den Datentyp double hat, wird der andere ebenso zu double konvertiert und das Ergebnis ist dann ebenfalls vom Typ double. Aus zwei float-Datentypen wird allerdings nicht (!) der Typ double, wenn der Wertebereich nicht ausreicht (also keine Ad-hoc-Konvertierung). Stattdessen wird der wohldefinierte Wert Infinity zurückgegeben. Wenn der entstehende Datentyp größer ist als der zu konvertierende Datentyp, funktioniert die Konvertierung offensichtlich ohne Probleme ad hoc, da keine Informationen verloren gehen können. Wenn in Ausdrücken Verbindungen zwischen verschiedenen Familien von Datentypen (etwa char mit int oder byte mit double) durchgeführt werden, wird automatisch nur so umgewandelt, dass keine Information verloren gehen kann.
Objektorientierte Programmierung in Java
133
4 – Grundlegende Sprachelemente von Java
Explizite Typkonvertierung Eine explizite Konvertierung ist immer dann notwendig, wenn Sie eine Umwandlung in einen anderen Datentyp wünschen und diese nicht ad hoc aufgrund der oben beschriebenen Situationen erfolgt. Dazu müssen Sie in der Regel den Casting-Operator verwenden. Der Casting-Operator besteht bei Umwandlungen in einen primitiven Datentyp nur aus dem gewünschten Datentypnamen in runden Klammern. Mit Einschränkungen lassen sich sogar Klasseninstanzen in Instanzen anderer Klassen konvertieren (sowohl ad hoc als auch explizit). Die wesentliche Einschränkung ist, dass die Klassen durch Vererbung miteinander verbunden sein müssen. Allgemein gilt, dass ein Objekt einer Klasse in seine Superklasse konvertiert werden kann. Dabei gehen die spezifischen Informationen, die nur in der zu konvertierenden Subklasse vorhanden sind, natürlich verloren. Bezüglich des umgekehrten Wegs muss beachtet werden, dass eine Subklasse in der Regel mehr Informationen enthält als die Superklasse und diese können nicht einfach durch Casting generiert werden. Ein Objekt einer Subklasse, welches in den Typ einer Superklasse konvertiert wurde, kann aber per Casting wieder in seinen Subklassentyp zurückverwandelt werden. Alternativ zum Einsatz des Casting-Operators gibt es in manchen Situationen Methoden, die eine gewünschte Typkonvertierung durchführen.
Konvertierung zwischen primitiven Datentypen und Objekten Die direkte Konvertierung zwischen primitiven Datentypen und Objekten ist in Java nicht möglich – weder ad hoc noch durch explizites Casting. Das hat sehr weitreichende Konsequenzen, denn ein String ist ja beispielsweise in Java kein primitiver Datentyp. Aber diese Unterbindung der direkten Umwandlung ist kein Mangel von Java, sondern ein exorbitantes Sicherheits- und Stabilitätsfeature gegenüber den Sprachen, wo das funktioniert. Was hieße es denn, wenn man die Konvertierung von primitiven Datentypen in Objekte zuließe? Wenn Sie ein Objekt vom Typ Schwein erstellen und dann durch den primitiven Typ 5 teilen? Was wollen Sie erhalten? Schnitzel? Die Verbindung von Objekten und primitiven Datentypen könnte unzählige unsinnige Konstellationen bewirken, die aber für eine Lösung eine Typkonvertierung erzwingen würden. Es gibt zwar auch diverse Situationen, in denen eine Verbindung Sinn macht, aber wenn Java für deren Behandlung Casting zulassen würde, müsste man ein riesiges Geflecht an Situationen festlegen, wann es geht und wann nicht. Das
134
Datentypen und Typumwandlungen
könnte man nicht mit einem einfachen Konzept realisieren und es passt einfach nicht zu Java, zig Ausnahmen und Sondersituationen zu postulieren1.
Wrapper Statt einer direkten Konvertierung zwischen Objekten und primitiven Datentypen gibt es im Java-Paket java.lang als Ersatz Sonderklassen, die primitiven Datentypen entsprechen und perfekt in das Objektkonzept von Java passen. Man nennt sie Wrapper-Klassen oder kurz Wrapper2. Mit diesen Klassen können Sie mit Hilfe des new-Operators jeweils ein Objektgegenstück zu jedem primitiven Datentyp erstellen und mit den in den Klassen definierten Methoden lassen sich diverse Aktionen ausführen. Es gibt für jeden primitiven Datentyp ein Wrapper-Äquivalent. Das Wrapper-Verfahren eignet sich sowohl dafür, aus einem primitiven Datentyp ein Objekt zu erstellen, das diesen in der Welt der Objekte repräsentiert, als auch umgekehrt aus einem Objekt einen solchen zu extrahieren. Beispiel: Integer ob1 = new Integer(42);
Das Objekt ob1 ist eine Instanz der Klasse Integer und bekommt direkt den primitiven int-Wert 42 übergeben. Das folgende Beispiel demonstriert, wo eine solche Verwendung einer Wrapper-Klasse sinnvoll ist. Das Beispiel zeigt ein Java-Programm mit grafischer Oberfläche, in dem das Ergebnis einer Multiplikation ausgegeben werden soll. Zur Ausgabe auf so einer grafischen Oberfläche haben wir als einzig sinnvolle Möglichkeit eine Methode drawString() zur Verfügung. Das Ergebnis einer Multiplikation von primitiven numerischen Werten ergibt aber keinen String. Die Methode drawString() kann nicht (direkt) zur Ausgabe verwendet werden, denn sie benötigt als Parameter einen String. Sie müssen das Ergebnis der Multiplikation in ein Objekt einpacken und daraus einen String extrahieren. Letzteres ist für jedes Objekt möglich, denn jedes Objekt erbt von der Klasse Object eine passende Methode toString(). Damit kann man im Prinzip aus jedem beliebigen Objekt einen String extrahieren.
1. In Java 1.5 wurde allerdings für solche Situationen dennoch ein Konzept mit Namen Autoboxing eingeführt. Dies sprengt aber unseren Rahmen. 2. Zu Deutsch steht das für Ummanteln oder Einpacken.
Objektorientierte Programmierung in Java
135
4 – Grundlegende Sprachelemente von Java import java.awt.*; import java.awt.event.*; public class A extends Frame { A() { init(); } private void init() { addWindowListener(new WindowAdapter() { public void windowClosing(WindowEvent evt) { System.exit (0); } } ); } public static void main(String[] args) { A f = new A(); f.setSize(100,100); f.setVisible(true); } public void paint(Graphics g) { Integer erg = new Integer(3*3); g.setFont(new Font("TimesRoman",Font.BOLD,48)); g.drawString(erg.toString(), 50, 75); } }
Abb. 4.1: In dem Programm wird auf der grafischen Oberfläche ein numerischer Wert ausgegeben
Das Beispiel definiert mit A() {init();} einen eigenen Konstruktor und ruft dort eine Initialisierungsmethode auf. Auf diese Thematik gehen wir im nächsten Kapitel genauer ein. Weiter wird in dem Quellcode eine Java-Technik mit Namen AWT (Abstract Windowing Toolkit) zur Generierung einer grafischen Oberfläche verwendet. Die Behandlung der AWT-Details sprengt unseren
136
Datentypen und Typumwandlungen
Rahmen. Nur so weit ein paar kurze Erklärungen zu den wichtigsten Bestandteilen des Quellcodes:
쐌 Als Subklasse von java.awt.Frame stellt unsere Klasse A die Möglichkeit bereit, eine grafische Oberfläche zu nutzen. 쐌 In der main()-Methode wird das grafische Fenster erzeugt und angezeigt. 쐌 In der Initialisierungsmethode wird eine Funktionalität implementiert, um das Programm mit einem Klick auf den Schließenbutton des Fensters zu beenden. 쐌 In der paint()-Methode wird die grafische AWT-Oberfläche gezeichnet. Die Methode wird automatisch aufgerufen, da die Klasse A eine Subklasse von java.awt.Frame ist. Wir haben im letzten Beispiel gesehen, wie ein Objekt mit einem primitiven Wert als Inhalt generiert wird. Gehen wir jetzt den umgekehrten Weg an, der das Extrahieren eines primitiven Werts aus einem Objekt vorsieht. Die Extrahierung aus einem per Wrapper erzeugten Objekt funktioniert mit den über die Wrapper-Klasse bereitgestellten Methoden. So stellt die Klasse Integer beispielsweise folgende Methoden zur Verfügung (Auswahl): Methode
Beschreibung
byte byteValue()
Rückgabe des Werts aus einem Integer-Objekt als byte.
double doubleValue()
Rückgabe des Werts aus einem Integer-Objekt als double
Rückgabe eines Integer-Objekts auf Grund verschiedener Übergabewerte
int intValue()
Rückgabe des Werts aus einem Integer-Objekt als int
Tabelle 4.9: Auswahl von Methoden der Integer-Klasse
Objektorientierte Programmierung in Java
137
4 – Grundlegende Sprachelemente von Java Methode
Beschreibung
long longValue()
Rückgabe des Werts aus einem Integer-Objekt als long
short shortValue()
Rückgabe des Werts aus einem Integer-Objekt als short
Tabelle 4.9: Auswahl von Methoden der Integer-Klasse (Forts.)
Die anderen Wrapper-Klassen stellen ähnliche Methoden bereit. Insbesondere die Klasse String, auf der sämtliche String-Repräsentationen von Java basieren, ist für den Fall der Extrahierung eines primitiven Werts aus einem Objekt zu erwähnen. Einmal kann jedes Objekt den enthaltenen Wert als String über toString() extrahieren. Die Konstruktoren der Wrapper-Klassen erlauben es zudem immer, einen String als Übergabewert anzugeben. Über diesen Weg führt dann am sinnvollsten die Extrahierung einer Zahl aus einem String. Das nachfolgende Beispiel extrahiert den Wert des Objekts als primitiven Datentyp int aus dem Integer-Objekt. Die verwendete Methode hier ist intValue(). class B { public static void main(String args[]) { Integer a = new Integer(args[0]); Integer b = new Integer(args[1]); System.out.println(a.intValue()*b.intValue()); } }
Wenn Sie das Programm mit zwei Integerwerten als Übergabewerte aufrufen (also beispielsweise java B 6 4), werden diese miteinander multipliziert und ausgegeben. Da in Java Übergabewerte an ein Programm immer als Strings übergeben werden (Befehlszeilenparameter in Form eines dynamischen String-Arrays), muss man eventuell dort zu übergebende primitive Werte mit geeignetem Wrapper behandeln und dann mit passenden Methoden die primitiven Werte extrahieren. Beachten Sie, dass das Programm nicht gegen Falscheingaben (falsches Format der Übergabewerte oder gar fehlende Parameter) gesichert ist.
138
Ausdrücke
4.3 Ausdrücke Ausdrücke sind das, was einen Wert in einer Programmierung repräsentiert. Sie drücken einen Wert entweder direkt (durch ein Literal) oder durch eine Berechnung (eine Operation) aus. Es kann sich aber auch um Kontrollflussausdrücke handeln, die den Ablauf der Programmausführungen festlegen. Ausdrücke können Konstanten, Variablen, Schlüsselwörter, Operatoren und andere Ausdrücke beinhalten. Wir haben in unseren bisherigen Beispielen natürlich schon diverse Java-Ausdrücke verwendet. Man kann Ausdrücke am einfachsten folgendermaßen definieren: Ausdrücke sind das Ergebnis der Verbindung von Operanden und Operatoren über die syntaktischen Regeln der Sprache. Ausdrücke werden also für die Durchführung von Operationen (Manipulationen) an Variablen oder Werten verwendet. Dabei sind Spezialfälle wie arithmetische Konstanten bzw. Literale kein Widerspruch.
4.3.1
Bewertung von Ausdrücken
Ausdrücke kommen selbstverständlich auch über komplizierte Kombinationen von Operatoren und Operanden vor. Deshalb muss Java diese Kombinationen bewerten, also eine Reihenfolge festlegen, wie diese komplexeren Ausdrücke auszuwerten sind. Das ist in der menschlichen Logik nicht anders, etwa die Punkt-vor-Strich-Rechnung in der Mathematik. Überhaupt lässt sich die Bewertung von Ausdrücken in Java meistens durch die Auswertung von Ausdrücken in der Mathematik intuitiv herleiten. Eine Bewertung basiert auf drei Grundbegriffen:
쐌 Operatorassoziativität 쐌 Operatorvorrang 쐌 Bewertungsreihenfolge Operatorassoziativität Die Operatorassoziativität ist die einfachste der Auswertungsregeln. Es geht darum, dass alle arithmetischen Operatoren Ausdrücke von links nach rechts auswerten (assoziieren). Wenn Operatoren gleicher Priorität in einem Ausdruck mehr als einmal auftauchen – wie beispielsweise der +-Operator bei dem Ausdruck 1 + 2 + 3 + 4 + 5 –, dann wird der am weitesten links erscheinende
Objektorientierte Programmierung in Java
139
4 – Grundlegende Sprachelemente von Java
Ausdruck zuerst ausgewertet, gefolgt von dem rechts daneben usw. Unterziehen wir folgende arithmetische Zuweisung einer näheren Betrachtung: x = 1 + 2 + 3 + 4 + 5;
In diesem Beispiel wird der Wert des Ausdrucks auf der rechten Seite des Gleichheitszeichens zusammengerechnet und der Variablen x auf der linken Seite zugeordnet. Für das Zusammenrechnen des Werts auf der rechten Seite bedeutet die Tatsache, dass der Operator + von links nach rechts assoziiert, dass der Wert von 1 + 2 zuerst berechnet wird. Erst im nächsten Schritt wird zu diesem Ergebnis dann der Wert 3 addiert und so fort, bis zuletzt die 5 addiert wird. Abschließend wird das Resultat dann der Variablen x zugewiesen. Immer, wenn derselbe Operator mehrfach benutzt wird, können Sie die Assoziativitätsregel anwenden. Diese Regel ist nicht ganz so trivial, wie sie im ersten Moment erscheint. Darüber lässt sich bewusst das Prinzip des Castings beeinflussen. Beachten Sie das nachfolgende kleine Beispiel: class A { public static void main (String args[]) { System.out.println("" + 1 + 2 + 3); System.out.println(1 + 2 + 3 + ""); } }
Die erste Ausgabe wird 123 sein, die zweite jedoch 6. Warum?
쐌 In der ersten Ausgabe wird zuerst "" + 1 ausgewertet. Das erzwingt ein Resultat vom Typ String, da hier der String-Verkettungsoperator zum Einsatz kommt (nicht die arithmetische Addition). Danach wird "1" + 2 ausgewertet ("1" ist ein String!) und es ergibt sich der String "12". Dann beginnt das Spiel von vorne. 쐌 In der zweiten Version wird zuerst 1 + 2 ausgewertet (die arithmetische Addition) und es entsteht die Zahl 3. Erst im letzten Schritt wird mit 6 + "" eine Konvertierung in einen String erzwungen.
140
Ausdrücke
Operatorvorrang Operatorvorrang bedeutet die Berücksichtigung der Priorität von Java-Operatoren. Wenn Sie einen Ausdruck mit unterschiedlichen Operatoren haben, muss Java entscheiden, wie Ausdrücke ausgewertet werden. Dazu wird zuerst der gesamte Ausdruck analysiert. Java hält sich strikt an Regeln der Operatorvorrangigkeit. Je höher ein Operator priorisiert ist, desto eher wird er ausgewertet. Klammern haben die höchste Priorität, weshalb jeder Ausdruck in Klammern zuerst ausgewertet wird.
Bewertungsreihenfolge Die Bewertungsreihenfolge bewertet im Gegensatz zum Operatorvorrang die Operanden. Die Bewertungsreihenfolge legt fest, welche Operatoren in einem Ausdruck zuerst benutzt werden und welche Operanden zu welchen Operatoren gehören. Außerdem dienen die Regeln für die Bewertungsreihenfolge zur Festlegung, wann Operanden ausgewertet werden. Es gibt drei plattformunabhängige Grundregeln in Java, wie ein Ausdruck ausgewertet wird:
쐌 Bei allen binären Operatoren wird der linke Operand vor dem rechten ausgewertet. 쐌 Zuerst werden immer die Operanden, danach erst die Operatoren ausgewertet. 쐌 Wenn mehrere Parameter, die durch Kommata voneinander getrennt sind, durch einen Methodenaufruf zur Verfügung gestellt werden, erfolgt die Auswertung dieser Parameter immer von links nach rechts.
4.3.2
Anweisungen
Anweisungen sind Syntaxstrukturen, die bestimmte Dinge ausführen. Anweisungen werden einfach der Reihe nach ausgeführt. Ausnahmen sind Kontrollflussanweisungen oder Ausnahmeanweisungen. Sie werden aufgrund eines besonderen Effekts ausgeführt. Man unterteilt Anweisungen nach ihrer Art.
Blockanweisung Eine Blockanweisung ist einfach eine Zusammenfassung von mehreren Anweisungen zu einer Blockstruktur. In Java erledigen das die geschweiften Klammern. Ein Anweisungsblock hat seinen eigenen Geltungsbereich für die
Objektorientierte Programmierung in Java
141
4 – Grundlegende Sprachelemente von Java
in ihm enthaltenen Anweisungen. Das bedeutet, dass in einigen Blockstrukturen lokale Variablen in diesem Block deklariert werden können, die außerhalb dieses Blocks nicht verfügbar sind und deren Existenz erlischt, wenn der Block ausgeführt wurde – beispielsweise schleifenlokale Variablen.
Deklarationsanweisung Eine Deklarationsanweisung ist eine Anweisung zur Einführung eines primitiven Datentyps, eines Datenfelds, einer Klasse, einer Schnittstelle oder eines Objekts.
Ausdrucksanweisung Ausdrucksanweisungen sind der offizielle Name für die folgenden Anweisungsarten:
쐌 Zuordnung 쐌 Inkrement 쐌 Dekrement 쐌 Methodenaufruf 쐌 Zuweisung Alle Ausdrucksanweisungen müssen in Java mit einem Semikolon beendet werden und eine Ausdrucksanweisung wird immer vollständig durchgeführt, bevor die nächste Anweisung ausgeführt wird. Leere Anweisung Es gibt in Java auch eine leere Anweisung. Sie besteht nur aus einem Semikolon und dient als Platzhalter.
Bezeichnete Anweisung Einer Java-Anweisung kann ein Bezeichner vorangestellt werden, der bei Sprunganweisungen als Sprungziel verwendet werden kann. Der Benennung folgt immer ein Doppelpunkt.
142
Ausdrücke
Auswahlanweisung Mit einer Auswahlanweisung sucht man einen von mehreren möglichen Kontrollflüssen eines Programms aus. Java unterstützt drei verschiedene Auswahlanweisungen:
쐌 if 쐌
if-else
쐌 switch-case-default Die if- und die if-else-Anweisung Eine if-Anweisung testet eine boolesche Variable oder einen Ausdruck. Wenn die boolesche Variable oder der Ausdruck den Wert true hat, wird die nachstehende Anweisung oder der nachfolgende Anweisungsblock ausgeführt. Ansonsten wird die nachstehende Anweisung oder der nachstehende Anweisungsblock ignoriert und mit dem folgenden Block bzw. der folgenden Anweisung fortgefahren. Eng verwandt ist die if-else-Anweisung, die genau genommen nur eine Erweiterung der if-Anweisung ist. Sie hat nur noch einen zusätzlichen elseTeil. Dieser else-Teil – eine Anweisung oder ein Block – wird dann ausgeführt, wenn der boolesche Test im if-Teil der Anweisung den Wert false ergibt.
Die switch-Anweisung Eine switch-Anweisung ermöglicht das Weitergeben des Kontrollflusses an eine von mehreren Anweisungen in ihrem Block mit Unteranweisungen. Sie tritt nur in Verbindung mit einem zweiten Schlüsselwort case auf, weshalb sie auch oft switch-case-Anweisung genannt wird. Ein Vorteil gegenüber der Variante mit if und else if ist, dass Sie auf einfache Weise mehrere Fälle unterscheiden können. Das ist zwar auch mit if und nachfolgendem else if möglich, indem Sie mehrere else-if-Abfragen hintereinander schreiben oder verschachteln. Jedoch erscheint die Fallunterscheidung mit switch vielen Programmierern als eleganter. Die Syntax einer switch-Fallunterscheidung sieht folgendermaßen aus:
Objektorientierte Programmierung in Java
143
4 – Grundlegende Sprachelemente von Java switch(Ausdruck) { case Wert1: ... break; case Wert2: ... break; ... default: ... }
Mit dem Schlüsselwort switch leiten Sie die Fallunterscheidung ein. Als Argument wird in den runden Klammern eine Variable oder ein Ausdruck angegeben, für dessen aktuellen Wert Sie die Fallunterscheidung durchführen. Dies ist der Testwert, der mit dem Wert hinter einem case-Fall übereinstimmen muss, wenn die dort nachgestellten Anweisungen ausgeführt werden sollen. Ein case-Block kann in geschweifte Klammern gesetzt werden, muss es aber nicht. Wenn es bei einem Test keine übereinstimmenden Werte gibt, wird die erste Anweisung hinter dem mit dem Schlüsselwort default bezeichneten Label ausgeführt, sofern dieser Block vorhanden ist. Andernfalls wird die erste Anweisung nach dem switch-Block ausgeführt. Der default-Zweig ist optional und kann entfallen, wenn es keinen Defaultfall geben soll. Er entspricht dem else-Teil bei if. Die zu testenden Ausdrücke müssen vom Typ byte, short, char oder int sein. Mit dieser Auswahlanweisung können Sie nur diskrete Werte testen (also Gleichheit). Es funktionieren keine Vergleiche auf „Kleiner“ oder „Größer“. Auch dürfen keine zwei case-Bezeichnungen im gleichen Block denselben Wert haben. So etwas ist in der if-else-if-Anweisung im Prinzip denkbar, wenn auch nicht sinnvoll, denn nach dem ersten Treffer werden folgende Treffer nicht mehr entdeckt, weil der Kontrollfluss aus der if-else-if-Anweisung herausspringt. Die Anweisung break, die am Ende jedes Blocks notiert ist, ist eine Sprunganweisung. Damit stellen Sie sicher, dass nach einem Treffer die nachfolgend notierten Fälle nicht ebenso ausgeführt werden. Beim letzten Block in der switch-case-Anweisung ist das break immer überflüssig.
144
Ausdrücke
Beim switch-case-Konstrukt handelt es sich um eine Fall-Through-Anweisung. Fehlt eine Sprunganweisung am Ende jedes Blocks, werden ab einem Treffer alle folgenden Anweisungen ausgeführt, was in der Praxis oft nicht gewünscht ist. In der Tat wird die switch-case-Anweisung fast immer in Verbindung mit break erläutert und auch so eingesetzt. Allerdings verliert man dann einen Hauptvorteil dieses Konstrukts wieder. Gerade der „durchfallende“ Charakter ohne break unterscheidet diese Konstruktion von der if-Struktur. Dort kann man nur mit erheblicher Mühe ein solches Verhalten erreichen. Beachten Sie die nachfolgenden Beispiele, in denen wir die switch-case-Anweisung sowohl mit als auch ohne break einsetzen. Das nachfolgende Beispiel zeigt einen sinnvollen Einsatz von switch-case, ohne die Anwendung von break. Das Beispiel simuliert die zufällige Auswahl eines Spruchs zum Tag1. Basis ist die Klasse java.util.Random, die ein Zufallsobjekt bereitstellt. Mit dessen Methode nextFloat() lässt sich eine Zufallszahl zwischen 0 und 1 ermitteln. Beachten Sie, dass wir wegen der Datentypen mit dem Faktor 5 multiplizieren und in den Datentyp byte casten. import java.util.*; public class B { Random a = new Random(); String spruchZumTag() { switch ((byte) (a.nextFloat() * 5)) { case 1: return ( "Holz in den Wald zu tragen ist toericht."); case 2: return ("Gut gemeint ist auch daneben."); case 3: return ("Adam und Eva hatten viele Vorteile: " + "Vor allem bekamen sie alle Zähne sofort und " + "auf einmal.");
1. Das könnte auch der Radiomoderator als Anregung brauchen, der mich gerade wieder langweilt ;-).
Objektorientierte Programmierung in Java
145
4 – Grundlegende Sprachelemente von Java case 4: return ("Alkohol macht gleichgueltig! " + "Mir doch egal!."); default: return ("Mir faellt nix ein."); } } public static void main(String[] args) { B u = new B(); System.out.println("Der Spruch zum Tag!"); System.out.println(u.spruchZumTag()); } }
Je nach ermittelter Zufallszahl wird in dem switch-case-Konstrukt ein Zweig gewählt und mit return der jeweilige Spruch zurückgegeben. In der main()Methode erfolgt dann die Ausgabe des Rückgabewerts mit System.out. println().
Abb. 4.2: Jeder Aufruf des Programms gibt einen zufällig ausgewählten Spruch zum Tag aus
Das letzte Beispiel kam ohne break-Anweisung aus, da mit return eine andere Sprunganweisung verwendet wurde. Deshalb soll das Beispiel umgeschrieben werden, um den Fall-Through-Charakter zu zeigen.
146
Ausdrücke import java.util.Random; public class C { public static void main(String[] args) { Random a = new Random(); switch ((byte) (a.nextFloat() * 5)) { case 1: System.out.println( "Holz in den Wald zu tragen ist toericht."); case 2: System.out.println( "Gut gemeint ist auch daneben."); case 3: System.out.println( "Adam und Eva hatten viele Vorteile: " + "Vor allem bekamen sie alle Zaehne sofort " + "und auf einmal."); case 4: System.out.println( "Alkohol macht gleichgueltig! Mir doch egal!."); default: System.out.println("Mir faellt nix ein."); } } }
Statt einer Methode mit einem String-Rückgabewert setzen wir hier die switch-case-Anweisung in der main()-Methode ein und geben in jedem caseFall direkt einen Text aus. Wie wird die Ausgabe dieser Variante des Programms aussehen? Im Gegensatz zu einer if-else-if-Anweisung springt der Kontrollfluss nach einem Treffer nicht automatisch aus der Struktur, sondern es wird vom ersten Treffer an die Struktur bis zum Ende ausgeführt! Das heißt, alle nachfolgend notierten Anweisungen werden explizit ausgeführt.
Objektorientierte Programmierung in Java
147
4 – Grundlegende Sprachelemente von Java
Abb. 4.3: Ein Programmaufruf kann die Ausgabe mehrerer Texte bewirken
Natürlich ist das Verfahren nicht sinnlos und Sie können es gezielt einsetzen, um den Durchlauf von mehreren Blöcken zu erreichen – etwa bei einer angeordneten Auswahl, in der ab einer gewissen Größe alle Folgeanweisungen Sinn machen. Wenn dieses Verhalten jedoch unterbunden werden soll, hat man (mindestens) zwei Möglichkeiten. Die eine Variante war das, was im ersten Beispiel gemacht wurde: ein Aufruf der Konstruktion über eine Methode, in der in jedem case-Zweig der Sprungbefehl return steht. Das ist aber nicht immer sinnvoll oder gewünscht. Die zweite Variante funktioniert jedoch sehr ähnlich. Man setzt in jedem Block als letzte Anweisung ein break. Dies ist eine weitere Sprunganweisung, die im Gegensatz zu return nicht auf das Verlassen einer Methode, sondern einer Blockstruktur ausgerichtet ist. Damit vermeiden Sie die Ausführung von mehr als einem Block. Die gesamte switch-case-Struktur wird nach einem Treffer über break beendet. Unser Beispiel sieht dann wie folgt aus (beachten Sie, dass beim default-Block als letzte Anweisung kein break notiert werden muss): import java.util.Random; public class D{ public static void main(String[] args) { Random a = new Random(); switch ((byte) (a.nextFloat() * 5)) { case 1: System.out.println("Holz in den Wald zu tragen ist toericht."); break;
148
Ausdrücke case 2: System.out.println("Gut gemeint ist auch daneben."); break; case 3: System.out.println( "Adam und Eva hatten viele Vorteile: " + "Vor allem bekamen sie alle Zaehne sofort " + "und auf einmal."); break; case 4: System.out.println( "Alkohol macht gleichgueltig!" + " Mir doch egal!."); break; default: System.out.println("Mir faellt nix ein."); } } }
Iterationsanweisung Iterationsanweisungen oder Wiederholungsanweisungen dienen der kontrollierten Wiederholung von Anweisungsfolgen. Es gibt in Java drei Arten von Iterationsanweisungen:
쐌 while 쐌
do
쐌 for Alle Formen der Iterationsanweisung testen eine boolesche Variable oder einen Ausdruck. Solange der Test den Wert true ergibt, wird die Unteranweisung oder der Block der Iterationsanweisung ausgeführt. Erst wenn die boolesche Variable oder der Ausdruck den Wert false ausweist, wird die Wiederholung eingestellt und die Kontrolle an die nächste Anweisung nach dem Iterationskonstrukt weitergegeben.
Objektorientierte Programmierung in Java
149
4 – Grundlegende Sprachelemente von Java
Die while-Anweisung Die while-Anweisung sieht wie folgt aus: while()
Wenn der in der Struktur überprüfte Ausdruck nicht von Anfang an true ist, wird der Block in der Unteranweisung niemals ausgeführt. Man nennt dies eine kopfgesteuerte oder abweisende Iterationsanweisung. Wenn er hingegen true ist, dann wird dieser Codeblock so lange wiederholt, bis er nicht mehr true ist. Alternativ kann durch eine Sprunganweisung die Kontrolle an eine Anweisung außerhalb der Schleife weitergegeben werden. Eine while-Schleife ist ein guter Kandidat für so genannte Endlosschleifen. Wenn Sie im Inneren der Schleife keine Situation schaffen, in der die Bedingung zum Durchlaufen der Schleife nicht mehr erfüllt ist oder durch einen Sprung die Schleife verlassen wird, hängen Sie in einer Endlosschleife.
Die do-Anweisung Die do-Anweisung (oder auch do-while-Anweisung genannt) testet eine boolesche Variable oder einen Ausdruck – aber erst am Ende eines Blocks, weshalb man von einer annehmenden oder fußgesteuerten Iterationsanweisung spricht. Damit wird der Codeblock innerhalb der do-Anweisung auf jeden Fall mindestens einmal ausgeführt. Die Syntax sieht so aus: do while();
Die for-Anweisung Eine for-Anweisung sieht wie folgt aus: for (; ;)
Diese Iterationsanweisung ist kopfgesteuert und fasst bereits im Schleifenkopf alle relevanten Anweisungen zur Steuerung der Schleife zusammen. Deshalb ist diese Iterationsanweisung bei den meisten Programmierern die beliebteste Schleifenform. Nach dem Schlüsselwort for kann optional ein Leerzeichen folgen. Der Initialisierungsteil kann eine durch Kommata getrennte Reihe von Deklarations- und Zuweisungsanweisungen enthalten. Das bedeutet, es kön-
150
Ausdrücke
nen mehrere Variablen im Initialisierungsteil verwendet werden. Erst durch ein Semikolon wird der Initialisierungsteil beendet. Diese Deklarationen haben nur Gültigkeit für den Bereich der for-Anweisung und ihrer Unteranweisungen. Es kann bei einer for-Schleife entweder ausschließlich mit schleifenlokalen oder ohne schleifenlokale Variablen gearbeitet werden. Der Testteil wird einmal pro Schleifendurchlauf neu bewertet und auch dieser Teil wird wieder durch ein Semikolon beendet. Der Inkrement- oder Dekrementteil kann ebenfalls eine durch Kommata getrennte Reihe von Ausdrücken enthalten, die einmal pro Durchlauf der Schleife bewertet werden. Dieser Teil des Schleifenkopfs wird für gewöhnlich dazu verwendet, einen Index, der im Testteil überprüft wird, zu inkrementieren (hochzuzählen) oder zu dekrementieren (herabzuzählen).
Sprunganweisung Sprunganweisungen geben die Steuerung bei ihrem Aufruf unmittelbar entweder an den Anfang oder das Ende des derzeitigen Blocks oder aber an bezeichnete Anweisungen weiter. Beachten Sie, dass es in Java keine gotoAnweisung gibt (obwohl das Schlüsselwort reserviert ist), mit der in der Vergangenheit in diversen Programmiersprachen so genannter Spagetticode erzeugt werden konnte, der nicht mehr wartbar war. Java kennt vier Arten von Sprunganweisungen: 쐌 쐌 쐌
break continue return 쐌 throw
Die break- und die return-Anweisung haben wir bereits mehrfach behandelt. Die continue-Anweisung kommt in Zusammenhang mit Iterationsanweisungen zum Einsatz und bricht dort einen Schleifendurchlauf ab. Durch continue wird also im Gegensatz zu break oder return nicht die gesamte Schleife abgebrochen, sondern es wird nur der aktuelle Schleifendurchlauf unterbrochen und zum Anfang der Schleife zurückgekehrt. Die throw-Anweisung erlaubt es, in Java eine Laufzeitausnahme des Programms zu erzeugen. Dies bedeutet, dass der normale Programmablauf durch eine Ausnahme unterbrochen wird, die zuerst vom Programm behandelt werden muss, bevor er fortgesetzt wird.
Objektorientierte Programmierung in Java
151
4 – Grundlegende Sprachelemente von Java
Synchronisationsanweisung und Schutzanweisung Synchronisationsanweisungen werden in Java für den sicheren Umgang mit Multithreading verwendet. Das Schlüsselwort synchronized wird zum Markieren von Methoden und Blöcken benutzt, die eventuell vor gleichzeitiger Verwendung geschützt werden sollen. Schutzanweisungen benötigt man zur sicheren Handhabung von Code, der Ausnahmen auslösen könnte (beispielsweise das Teilen durch null). Diese Anweisungen benutzen die Schlüsselwörter try, catch und finally. Java verfolgt zum Abfangen von Laufzeitfehlern ein Konzept, das mit so genannten Ausnahmen arbeitet.
Unerreichbare Anweisung Es ist leider durch eine unglückliche Kodierung des Quelltexts möglich, Codezeilen zu schreiben, die nie erreicht werden können. Dies nennt man unerreichbaren Code (unreachable Code). Solcher Code ist zwar eigentlich nicht schädlich in dem Sinne, dass er etwas Falsches tut, aber wenn man sich darauf verlässt, dass bestimmte Codezeilen ausgeführt werden und sie werden einfach nicht erreicht, kann der Schaden mindestens genauso groß sein. Der JavaCompiler bemerkt dies glücklicherweise fast immer und erzeugt einen Fehler zur Kompilierzeit.
4.4 Datenfelder (Arrays) Arrays bzw. Datenfelder bezeichnen allgemein eine Sammlung von Variablen, die alle über einen gemeinsamen Bezeichner und einen in eckigen Klammern notierten Index (in Java bei 0 beginnend) angesprochen werden können. Datenfelder sind immer dann von großem Nutzen, wenn eine Reihe von gleichartigen oder logisch zusammenfassbaren Informationen gespeichert werden sollen. Der Hauptvorteil ist, dass der Zugriff auf die einzelnen Einträge im Array über den numerischen Index erfolgen kann. Das lässt sich in Iterationsanweisungen nutzen. Datenfelder gehören in Java explizit nicht zu den primitiven Datentypen, sondern zu den Referenzvariablen, die Sie sich recht gut als (besondere) Objekte vorstellen können – mit allen Vorteilen, die damit verbunden sind. So können Sie in Java nicht unbemerkt über das Ende eines Datenfelds hinausgreifen und
152
Datenfelder (Arrays)
damit Programmfehler auslösen. Ebenso stehen diverse Eigenschaften und Methoden zum Umgang mit Datenfeldern bereit. Datenfelder sind damit in Java im Vergleich zu vielen anderen Programmiersprachen wie C/C++ oder PASCAL anders konzipiert. Ein Datenfeld ist eine Ansammlung von Objekten eines bestimmten Typs1, die über einen laufenden Index adressierbar sind. Datenfelder sind als Sammlung von anderen Objekten selbst nichts anderes als (besondere) Objekte. Sie werden wie normale Objekte dynamisch angelegt und am Ende ihrer Verwendung vom Garbage Collector beseitigt. Weiterhin stehen in Datenfeldern als Ableitung von Object alle Methoden dieser obersten Klasse zur Verfügung. Array-Bezeichner haben wie normale Objektvariablen einen Datentyp. Es kann sich bei Arrays um Datenfelder handeln, bestehend aus sämtlichen primitiven Variablentypen, aber auch anderen Datenfeldern oder Objekten. Letzteres ist besonders deshalb wichtig, da Java keine multidimensionalen Datenfelder im herkömmlichen Sinne unterstützt, sondern für so einen Fall ein Array mit Datenfeldern erwartet2. Verschachtelte Datenfelder wäre also die korrektere Bezeichnung.
Datenfelder als Sündenfall Vielleicht erinnern Sie sich, dass unter OO-Puristen Datenfelder als einer der drei Sündenfälle von Java gelten. OO-Puristen begründen diese Aussage damit, dass Datenfelder in Java im Vergleich zu normalen Objekten zwei wesentliche Besonderheiten haben:
쐌 Datenfelder gelten als klassenlose Objekte und sie verwenden keine Konstruktoren wie normale Klassen, bei denen der Konstruktor eine Methode mit dem Bezeichner der Klasse ist. Stattdessen wird der newOperator mit spezieller Syntax aufgerufen und einem jeden Array-Eintrag muss dann noch in einem zweiten Arbeitsschritt ein konkreter Inhalt zugewiesen werden. Oder man verwendet den Zuweisungsoperator mit spezieller Syntax, um in einem Schritt ein Datenfeldobjekt zu erzeugen und gleich mit Inhalt zu versehen. 쐌 Es können keine Subklassen eines Datenfelds definiert werden. 1. Es sind keine unterschiedlichen Typen innerhalb eines Arrays erlaubt (nur Subklassentypen des deklarierten Superklassentyps). Allerdings kann ein Array selbst wieder Arrays enthalten und damit ist das keinerlei Einschränkung. 2. Und die können wiederum weitere Datenfelder enthalten – im Prinzip beliebig viele Ebenen.
Objektorientierte Programmierung in Java
153
4 – Grundlegende Sprachelemente von Java
4.4.1
Allgemeines zum Erstellen eines Datenfelds
Um ein Datenfeld in Java zu erstellen, muss man normalerweise drei Schritte durchführen:
쐌 Zuerst erfolgt das Deklarieren einer Referenzvariablen, über die später auf das Datenfeld referenziert werden soll. 쐌 Dann erfolgt das Erzeugen des Datenfeldobjekts mit dem Zuweisen von Speicherplatz. 쐌 Abschließend füllen Sie das Datenfeld. Es ist natürlich möglich, mehrere Schritte zur Erstellung eines Datenfelds mit einer Anweisung zu erledigen, und Sie können ein Datenfeld bei der Initialisierung auch nur teilweise füllen. Die Datenfeldindizes müssen entweder vom Typ int (ganze 32-Bit-Zahl) sein oder als int festgesetzt werden. Daher ist die größtmögliche Datenfeldgröße 2.147.483.647. Dynamische Erzeugung Datenfelder können in Java zur Laufzeit des Programms durch die Verwendung des new-Operators dynamisch erstellt werden. Ein Datenfeld ist in Java – wie schon angedeutet – ein so genannter Referenztyp, ein besonderer Typ von Objekt. Die Besonderheit beruht darauf, dass Datenfelder klassenlose Objekte sind. Sie werden vom Compiler erzeugt, besitzen aber keine explizite Klassendefinition. Vom Java-Laufzeitsystem werden Datenfelder wie gewöhnliche Objekte behandelt. Das hat weitreichende (positive) Konsequenzen. So kann auf kein Datenfeldelement in Java unbemerkt zugegriffen werden, das noch nicht erstellt worden ist. Dadurch wird das Programm vor Abstürzen und nicht initialisierten Zeigern (Pointern) bewahrt. Des Weiteren besitzen Datenfelder Methoden und Instanzvariablen, die den Umgang mit ihnen deutlich vereinfachen.
4.4.2
Deklarieren von Datenfeldern
Das Erstellen einer Variablen, über die ein Array referenziert werden soll, ist in weiten Teilen identisch mit dem Deklarieren normaler Variablen. Es muss der Datentyp und der Name der Variablen festgelegt werden. Im Unterschied zur Deklaration einer normalen Variablen muss jedoch mit eckigen Klammern die Variable als Datenfeld gekennzeichnet werden. Es sind im Prinzip zwei Posi-
154
Datenfelder (Arrays)
tionen denkbar, in denen diese eckigen Klammern bei der Deklaration die Variable als Datenfeld kennzeichnen können – nach der Variablen oder nach dem Datentyp der Variablen. Beispiele: int a[]; int[] a;
Java unterstützt beide Syntaxtechniken! Sie können sich die Syntax auswählen, die Ihnen am besten zusagt und sogar mischen (bei multidimensionalen Datenfeldern). Allerdings tendieren mittlerweile die meisten Java-Programmierer dazu, die eckigen Klammern am Datentyp zu notieren. Das ist auch besser auf die Notation bei UML abgestimmt. Bei der Deklaration von Variablen, die auf Datenfelder mit anderen Datenfeldern als Inhalt (multidimensionale Datenfelder) verweisen sollen, wird pro Dimension einfach ein weiteres Paar an eckigen Klammern angefügt. Beispiele: int a [][]; int[] a[]; int[][][] a;
Ein Datenfeld wird Ihnen übrigens bei jedem Programm begegnen. In der Methodenunterschrift public static void main(String[] args) steht der Parameter für ein String-Array mit Übergabewerten an das Programm. Java verwendet so genannte semidynamische Datenfelder, da die Größe eines Datenfelds nicht bei der Deklaration festgelegt werden muss, sondern sich auch erst zur Laufzeit ergeben kann. Genau das ist bei Übergabewerten an ein Programm der Fall. Die Anzahl der Parameter, die der Anwender beim Programmaufruf angibt, legt zur Laufzeit die Größe des Datenfelds fest. Das vorangestellte semi bedeutet, dass ein Datenfeld nicht mehr in der Größe verändert werden kann, wenn es einmal in der Größe festgelegt wurde.
4.4.3
Konkretes Erstellen von Datenfeldern in Java
Datenfelder können auf verschiedene Arten erstellt werden. Einmal mittels des new-Operators – dies ist die direkte Erzeugung eines Datenfeldobjekts (und eines anderen Objekts). Daneben gibt es die Möglichkeit, durch direktes Initialisieren des Array-Inhalts ein Datenfeldobjekt zu erzeugen.
Objektorientierte Programmierung in Java
155
4 – Grundlegende Sprachelemente von Java
Erzeugung mit dem new-Operator Bei der direkten Erzeugung eines Datenfeldobjekts wird eine neue Instanz eines Datenfelds erstellt. Das erfolgt schematisch so: new []
Also etwa wie in den folgenden Beispielen: new int[42] new double[77][3]
In der Regel wird das Array direkt einer Variablen zugewiesen. Das sieht dann schematisch so aus: = new []
Beispiele: a = new int[42] b = new double[77][3]
Diese Variablen, denen das Array zugewiesen werden soll, müssen vorher entsprechend (also mit der passenden Dimensionsangabe) deklariert worden sein. In der Regel fasst man diese Deklaration und die Zuweisung des Datenfelds in einem Schritt zusammen. Etwa wie im folgenden Beispiel: int[] a = new int[42];
Es entsteht ein neues Array vom Datentyp int[] mit 42 int-Elementen. Die Anzahl der Elemente muss bei der direkten Erzeugung eines Datenfeldobjekts angegeben werden. Beachten Sie, dass ein so erzeugtes Datenfeld noch keinen speziellen Inhalt hat. Beim Erzeugen eines Datenfeldobjekts mit new werden alle Elemente des Datenfelds nur automatisch mit den Defaultwerten des jeweiligen Datentyps initialisiert. Schauen wir uns ein praktisches Beispiel an: public class A { public static void main(String[] args) { int[] prim = new int[5];
156
Datenfelder (Arrays) System.out.println("Die Defaultwerte"); for (int i = 0; i < prim.length; i++) { System.out.print(prim[i] + " "); } // Den Array-Elementen werden Werte zugewiesen prim[0] = 1; prim[1] = 2; prim[2] = 3; prim[3] = 5; prim[4] = 7; System.out.println( "\nDie neu zugewiesenen Werte"); for (int i = 0; i < prim.length; i++) { System.out.print(prim[i] + " "); } } }
Die erste Anweisung in der main()-Methode legt ein int-Datenfeld mit fünf Elementen an. Die nachfolgende for-Schleife gibt den Inhalt aus. Beachten Sie die Anweisung for (int i = 0; i < prim.length; i++). Im Initialisierungsteil wird eine schleifenlokale Variable angelegt. Mit prim.length wird direkt die Größe des Datenfelds abgefragt. Datenfelder sind ja Objekte und als solche bringen sie einige interessante Methoden und Eigenschaften mit. Die Eigenschaft length enthält immer die Anzahl der Elemente in einem Array. Der Inkrementteil verwendet den Operator ++ zum Erhöhen der Zählvariable der Schleife. Die Anwendung dieses Operators auf diese Weise (bzw. -- für ein Dekrement) ist in allen Schleifen üblich. In der Schleife wird bei jedem Durchlauf der Inhalt des jeweiligen Elements ausgegeben. Beachten Sie, dass hier die Methode print()1 verwendet wird. Diese erzeugt im Gegensatz zu println() keinen Zeilenvorschub. Aus diesem Grund wird in System.out. println("\nDie neu zugewiesenen Werte"); die Escape-Sequenz \n verwendet, um einen Zeilenvorschub vor der Ausgabe des Textes zu erzeugen. Die nachfolgenden Schritte weisen den Array-Elementen die ersten fünf Primzahlen zu, die dann mit einer erneuten Schleife ausgegeben werden.
1. Nicht println().
Objektorientierte Programmierung in Java
157
4 – Grundlegende Sprachelemente von Java
Abb. 4.4: Die Inhalte des Datenfelds vor und nach der Zuweisung von Werten
Erzeugung mit direktem Initialisieren des Datenfeldinhalts Wenn Sie den Inhalt eines Datenfelds direkt bei der Erzeugung eines Datenfeldobjekts angeben wollen, müssen Sie nach dem Gleichheitszeichen die Elemente des Arrays in geschweiften Klammern und mit Komma getrennt angeben, etwa wie in dem nachfolgenden Beispiel: int[] prim = {1, 2, 3, 5, 7};
Ein Array mit der Anzahl der angegebenen Elemente wird automatisch erzeugt. Auch ein multidimensionales Datenfeld lässt sich direkt mit Inhalt erzeugen. So wird etwa ein 2 x 2-Array angelegt und gleich gefüllt: int[][] a = {{1, 2}, {3, 4}};
Diese Technik der Erzeugung und Initialisierung von Arrays nennt man auch „Literale Initialisierung“.
Weiterentwicklung des Bauernhof-API-Projekts Im letzten Kapitel haben wir begonnen, ein API aus Klassen aufzubauen und anzuwenden. Dieses wollen wir nun in einer weiteren Anwendung verwenden. Entsprechend handelt es sich hier nicht um eine Weiterentwicklung des Bauernhof-API selbst. Aber das Projekt1 wird weiterentwickelt. Sorgen Sie dafür, dass das Bauernhof-API Ihnen zur Verfügung steht. Am einfachsten kopieren 1. Das Projekt hat ja das Lernen von OOP anhand von Java als Ziel.
158
Datenfelder (Arrays)
Sie den ganzen Ordner des bisherigen Projekts. Das API selbst wird nicht verändert, nur verwendet. Arbeit stecken wir in das Programm selbst. Stellen Sie sich folgendes Szenario vor: Wir haben unser Bauernhof-API erfolgreich bei einem Kunden verwendet und wollen nun Geld damit verdienen. In der Praxis ist es ja meist so, dass sich eine Programmierung erst dann bezahlt macht, wenn man sie mehrfach verwenden kann. Unser erster Kunde soll also mit der Umsetzung seines landwirtschaftlichen Produktionsbetriebs hochzufrieden gewesen sein und empfiehlt uns in seinem Bauernverband. Und prompt bekommen wir einen Auftrag von einem Kollegen, der nun aber mehr Tiere als unser erster Kunde hat. Und da er auch kein so persönliches Verhältnis zu seinen tierischen Angestellten pflegt, haben seine Tiere nicht einmal Namen1. Es bietet sich an, die verschiedenen Tiere in Datenfeldern (sozusagen Ställe) zu halten. package de.rjs.bauernhof; import java.util.Random; import de.rjs.bauernhof.ebene2.Kuh; import de.rjs.bauernhof.ebene3.Schaf; import de.rjs.bauernhof.ebene3.Schwein; public class Bauernhof { Random zufall = new Random(); void initMilch(Kuh[] rindviecher) { for (int i = 0; i < rindviecher.length; i++) { rindviecher[i].setMilch(zufall.nextInt(30)); } } void initWolle(Schaf[] bloecker) { for (int i = 0; i < bloecker.length; i++) { bloecker[i].setWolle(zufall.nextInt(16)); } }
1. Das Dumme an geschriebenen Texten ist, dass Sie die Stellen mit meiner in Schulungen so geschätzten, feinen Ironie :-)) nicht am Tonfall erkennen können. Ich hoffe, Sie können sich den Tonfall hinzudenken ;-). Smileys helfen zwar, aber ich möchte nicht nach jedem zweiten Wort ein selbiges notieren ;-))))).
Objektorientierte Programmierung in Java
159
4 – Grundlegende Sprachelemente von Java void initGeschlecht(Schwein[] grunzer) { for (int i = 0; i < grunzer.length; i++) { grunzer[i].setGeschlecht(zufall.nextBoolean()); } } void initAlter(Schwein[] grunzer) { for (int i = 0; i < grunzer.length; i++) { grunzer[i].setAlter((byte) zufall.nextInt(7)); } } void printMilch(Kuh[] rindviecher) { for (int i = 0; i < rindviecher.length; i++) { System.out.println("Die Kuh Nr. " + (i + 1) + " gibt " + rindviecher[i].getMilch() + " Liter Milch am Tag."); } } void printAlter(Schwein[] grunzer) { for (int i = 0; i < grunzer.length; i++) { System.out.println("Das Tier Nr. " + (i + 1) + " der " + grunzer.getClass() + " ist " + grunzer[i].getAlter() + " Jahre alt."); } } void printWolle(Schaf[] bloeker) { for (int i = 0; i < bloeker.length; i++) { System.out.println("Das Schaf Nr. " + (i + 1) + " gibt " + bloeker[i].getWolle() + " Kilo Wolle."); } } void printGeschlecht(Schwein[] grunzer) { for (int i = 0; i < grunzer.length; i++) { System.out.println("Ist das Tier Nr " + (i + 1) + " der " + grunzer.getClass() + " ein Eber? " + grunzer[i].isGeschlecht()); } }
160
Datenfelder (Arrays) public static void main(String[] args) { Kuh[] rindviecher = new Kuh[5]; for (int i = 0; i < rindviecher.length; i++) { rindviecher[i] = new Kuh(); } Schaf bloeker[] = new Schaf[7]; for (int i = 0; i < bloeker.length; i++) { bloeker[i] = new Schaf(); } Schwein[] grunzer = {new Schwein(), new Schwein()}; Bauernhof schmidt = new Bauernhof(); schmidt.initAlter(grunzer); schmidt.initGeschlecht(grunzer); schmidt.initMilch(rindviecher); schmidt.initWolle(bloeker); schmidt.printAlter(grunzer); schmidt.printGeschlecht(grunzer); schmidt.printMilch(rindviecher); schmidt.printWolle(bloeker); } }
In dem Beispiel wird wieder ein Zufallsobjekt verwendet. Dies soll ausschließlich den Programmablauf etwas interessanter gestalten und hat keine tiefer gehende Bedeutung. In der main()-Methode wird als erster Schritt ein Datenfeld vom Typ Kuh mit fünf Elementen erzeugt (Kuh[] rindviecher = new Kuh[5];). Diese haben nach diesem Schritt noch keine expliziten Werte, sondern wurden mit einem Defaultwert für Objekte (null) vorbelegt. Der Stall ist sozusagen noch leer. Erst mit der nachfolgenden Schleife füllen wir das Datenfeld mit Rindviechern (for (int i = 0; i < rindviecher.length; i++) { rindviecher[i] = new Kuh();}). Für Schafe machen wir das Gleiche. Beachten Sie hier, dass die eckigen Klammern zur Demonstration am Bezeichner notiert werden (Schaf bloeker[]). Für die Schweine soll die Technik des direkten Initialisierens demonstriert werden. Die Zeile Schwein[] grunzer = { new Schwein(), new Schwein() }; er-
Objektorientierte Programmierung in Java
161
4 – Grundlegende Sprachelemente von Java
zeugt ein Datenfeld vom Typ Schwein und im Schweinestall befinden sich danach auch gleich zwei Schweine. Mit der Zeile Bauernhof schmidt = new Bauernhof(); wird ein Objekt der aktuellen Klasse erzeugt. Der Bezeichner deutet den Namen unseres Kunden an1. In der Folge rufen wir vier Initialisierungsmethoden auf, die sprechend benannt wurden. Sie weisen in Schleifen den Elementen des jeweiligen Datenfelds sinnvolle Zufallswerte zu. Die abschließenden vier Ausgabemethoden (ebenso mit sprechenden Bezeichnern versehen) geben ebenfalls über Schleifen diese Werte – mit umgebenden Texten aufbereitet – jeweils wieder aus. Mit der Methode getClass() können Sie übrigens bei jedem Objekt feststellen, aus welcher Klasse es erzeugt wurde.
Abb. 4.5: Die Anwendung von Datenfeldern mit beliebigen Objekten als Inhalt
Beachten Sie, dass wir im letzten Beispiel einige Dinge tun, die in der Praxis in Konstruktoren verlagert werden, etwa das Initialisieren von Schweinen2. Wir werden das im nächsten Kapitel angehen.
1. Bitte Schmidt mit dt – das ist wichtig ;-). 2. ;-)
162
Zusammenfassung
4.5 Zusammenfassung In diesem Kapitel haben Sie die grundlegenden Sprachelemente und die zentralen Syntaxstrukturen von Java kennen gelernt. Beginnend mit der Erklärung der verschiedenen Token (Bezeichner, Schlüsselwörter, Literale, Operatoren und Trennzeichen) in Java haben wir Datentypen und Typumwandlungen samt Wrapper-Klassen, Referenzvergleiche, Ausdrücke und Anweisungen (vor allem Iteration- und Auswahlanweisungen) sowie Datenfelder als eine besondere Form eines Objekts besprochen.
Objektorientierte Programmierung in Java
163
5
Erweiterte OO-Techniken
Dieses Kapitel führt Sie in erweiterte Techniken der objektorientierten Programmierung ein, die Sie für einen vollständigen Überblick noch benötigen. Sie verfügen nun bereits über ein Verständnis für die elementarsten Kernprinzipien der OOP und der Java-Syntax. In Kapitel 2 erhielten Sie eine Übersicht über diese Kernkonzepte der Objektorientierung. Und wenn Sie die dortigen Schlagwörter ansehen, fehlt bis zu dieser Stelle die Behandlung der Polymorphie einschließlich Überschreiben und Überladen, der Schnittstellen, der abstrakten Klassen und der generischen Klassen. Diese Techniken lernen Sie in diesem Kapitel kennen, wobei wir sie explizit anhand der konkreten Java-Syntax demonstrieren.
5.1 Polymorphie Der Begriff Polymorphie ist leider nicht ganz eindeutig und wird in verschiedenen Quellen unterschiedlich eng ausgelegt. Generell bedeutet er Vielgestaltigkeit. Auch der Begriff des späten oder dynamischen Bindens wird dafür verwendet. Allgemein bezeichnet man damit eine Auswahl einer Operation, die nicht nur vom Bezeichner der Operation abhängt, sondern ebenso vom Objekttyp und der Signatur. Diese zentrale OO-Technik gestattet Methoden gleichen Namens in der gleichen Klasse sowie Methoden identischer Signatur in Klassen, die in Vererbungsbeziehung zueinander stehen. Der Compiler bzw. das Laufzeitsystem entscheiden beim Aufruf einer Methode, welche Methode mit dem Aufruf gemeint ist1. Die Polymorphie ermöglicht es ebenso, dass ein Objekt einer Subklasse so wie ein Objekt jeder seiner Superklassen verwendet werden kann. Überall dort, wo ein Objekt einer Superklasse erwartet wird, kann auch ein Objekt einer Subklasse notiert werden. Die zentralen Techniken zur Implementierung von Polymorphie nennen sich Überschreiben und Überladen. 1. Deshalb wird auch in diesem Zusammenhang von „late binding“ oder dynamischer Bindung gesprochen, da in vielen Situationen zur Kompilierzeit keine Zuordnung einer konkreten Methodenimplementierung erfolgen kann.
Objektorientierte Programmierung in Java
165
5 – Erweiterte OO-Techniken
5.1.1
Überschreiben von Methoden
Wenn Sie versuchen, in einer Klasse mehrere Methoden zu definieren, die sich in der Signatur nicht signifikant unterscheiden, wird beim Übersetzen ein Compiler-Fehler erzeugt. In unterschiedlichen Klassen können jedoch Methoden mit gleichem Namen und sogar identischer Parameterliste deklariert werden, auch wenn diese über Vererbung in Verbindung stehen (also in einer Superklasse-Subklassen-Beziehung) und egal über wie viele Ebenen. Beim Überschreiben oder Overriding1 bzw. Überlagern einer Methode wird in einer Subklasse eine Methode einer Superklasse mit der exakt identischen Signatur (gleiche Anzahl und gleicher Typ der Argumente) sowie identischem Rückgabetyp neu definiert und implementiert. Die überschreibende Methode verfeinert und ersetzt die Methodenimplementierung der Superklasse. Dabei wird die Methode der Superklasse verdeckt.
Warum überschreiben? Das Überschreiben einer Methode ist in der OOP aus verschiedenen Gründen sehr sinnvoll bzw. sogar oft unabdingbar für eine vernünftige Implementierung einer Vererbungshierarchie:
쐌 Eine Superklasse stellt noch keine konkrete Implementierung einer Methode bereit. Dies geschieht zum Beispiel bei der Definition einer so genannten abstrakten Methode oder bei der leeren Implementierung. Erst in einer Subklasse erfolgt die konkrete Spezifikation der Methode. Das bedeutet, dass eine der Subklassen die Methodendeklaration der Superklasse überschreiben muss, damit überhaupt eine entsprechende Funktionalität zur Verfügung steht. 쐌 Die Superklasse implementiert bereits eine gewisse Funktionalität der Methode, aber in der abgeleiteten Klasse soll die Funktionalität erweitert werden. Das bedeutet, es wird die bisherige Implementierung intern aufgerufen und dann die zusätzliche, in der überschriebenen Version hinzugefügte Funktionalität ausgeführt.
1. Overriding bedeutet eigentlich genau übersetzt „Überdefinieren“. Die deutsche Definition ist wahrscheinlich nur aufgrund einer fehlerhaften Übersetzung bzw. Verwechselung mit „Overwriting“ entstanden.
166
Polymorphie
쐌 Zur Optimierung einer Methode muss Funktionalität geändert werden, die bereits in einer Superklasse implementiert wurde. Mit dem Überschreiben kann eine Spezialisierung realisiert werden und eine Methode in der Subklasse anders als in der Superklasse arbeiten. Beispiele für das Überschreiben Das nachfolgende Beispiel definiert in einer Superklasse mit Namen A eine Methode void test(), die in der Subklasse B (hier als Programm mit einer main()-Methode) neu implementiert wird.
Abb. 5.1: Das UML-Klassendiagramm der Super-Subklassen-Beziehung
Hier ist der Quellcode der Superklasse: public class A { void test(){ System.out.println("Super, echt klasse."); } }
Und das ist das Listing der Subklasse: public class B extends A { void test(){ System.out.println( "Nicht super, gar nicht klasse."); }
Objektorientierte Programmierung in Java
167
5 – Erweiterte OO-Techniken public static void main(String[] args) { new B().test(); } }
In Abbildung 5.2 sehen Sie die Ausgabe des Beispielprogramms. Offensichtlich wird die Methode der Superklasse verdeckt und die Methode der Subklasse aufgerufen.
Abb. 5.2: Die Methode der Subklasse wird dynamisch ausgewählt
Schauen wir uns noch ein Beispiel an. In diesem wird ein Konstruktor überschrieben. Fortgeschrittene Leser werden nun jedoch stutzen. Im eigentlichen Sinn können Konstruktoren rein technisch nicht überschrieben werden. Konstruktoren müssen ja immer den gleichen Namen wie die Klasse haben und da umgekehrt beim Überschreiben die exakte Methodensignatur redefiniert werden muss, ließe sich das nur für Klassen gleichen Namens realisieren. Das ist bei einer Superklassen-Subklassen-Beziehung trotz möglicher Namensräume so gut wie nie der Fall. Zudem müssen Sie beachten, dass Konstruktoren aus diesem Grund auch nicht im eigentlichen Sinne vererbt, sondern in jeder Klasse immer neu erstellt werden. Wenn ein Konstruktor einer Klasse aufgerufen wird, wird zuvor auch der Konstruktor mit der gleichen Parameterliste der jeweiligen Superklasse aufgerufen. Nun ist es aber ein Argument für das Überschreiben von Methoden, dass fehlende Funktionalität in der Methode ergänzt oder geändert wird. Wenn Sie beim Konstruktor für eine Klasse etwas ändern oder ergänzen wollen, redefinieren Sie einen Konstruktor in einer Subklasse. Und dann können Sie mit der nötigen Vorsicht von einer Art „Überschreiben“ sprechen. Schauen wir uns das folgende Listing an:
168
Polymorphie public class C { static int z = 0; public C() { System.out.println( "Objekt Nr. " + ++z + " wird erzeugt"); } public static void main(String[] args) { for (int i = 0; i < 10; i++) { new C(); } } }
In der Klasse C wird der Konstruktor redefiniert. Das bedeutet, die Originalfunktionalität des Default-Konstruktors (im Wesentlichen das Erzeugen des Objekts mit Speicherallokierung und Initialisierung des Objekts) wird abgearbeitet und dabei wird die erweiterte Funktionalität ebenfalls durchgeführt. Dies entspricht implizit dem Fall, dass dort als erste Anweisung super() zum Aufruf des übergeordneten Konstruktors aufgerufen wird (siehe unten). In unserem Beispiel wird als Erweiterung der Funktionalität bei der Erzeugung eines Objekts eine Meldung ausgegeben. Dabei verwenden wir eine Klassenvariable, um die Anzahl der erzeugten Instanzen mitzuzählen. Beachten Sie die vorangestellte Verwendung des ++-Operators in der Zeile System.out. println("Objekt Nr. " + ++z + " wird erzeugt");. In der main()-Methode werden mit einer Schleife zehn Objekte vom Typ C erzeugt.
Abb. 5.3: Beim Erzeugen eines Objekts wird eine Information ausgegeben
Objektorientierte Programmierung in Java
169
5 – Erweiterte OO-Techniken
Beschränkungen beim Überschreiben Das Überschreiben von Methoden unterliegt in Java gewissen Beschränkungen. Methoden können sich vor allem nicht überschreiben, wenn es relevante Unterschiede in der Methodensignatur gibt. In Bezug auf Parameter haben wir ja bereits deutlich festgehalten, dass deren Anzahl und Typ (nicht der Name – dieser ist zum Aufrufzeitpunkt ja irrelevant) übereinstimmen müssen. Aber die Übereinstimmung betrifft auch Rückgabewerte und sogar Modifizierer.
Modifizierer Eine Instanzmethode kann keine Klassenmethode überschreiben und umgekehrt. Also muss beim Überschreiben zweier Methoden die Methodendeklaration bezüglich der Verwendung von static konsistent sein. Wenn eine Methode als final deklariert wird, kann sie grundsätzlich in einer Subklasse nicht überschrieben werden. Das ist genau die Bedeutung des Modifizierers final bei Methoden. Finale Methoden sind per Definition in Java solche Methoden, die nicht mehr überschrieben werden können. Ein anderer Modifizierer, der Überschreiben im eigentlichen Sinn verhindert, ist private. Wenn eine Methode in einer Klasse als private deklariert wird, ist sie in der Subklasse nicht mehr sichtbar. Eine Redefinition einer Methode mit gleicher Signatur ist zwar möglich, aber da die Methode der Superklasse unsichtbar ist, ist das kein Überschreiben im eigentlichen Sinne. Allgemein müssen Sie beim Überschreiben zweier Methoden beachten, dass die überschreibende Methode mindestens die gleiche Sichtbarkeit haben muss wie die überschriebene Methode. Als Eselsbrücke kann man die Mengenlehre heranziehen. Die verdeckende Menge darf nicht kleiner als die Menge sein, die verdeckt werden soll. Aber das ist eine etwas gewagte Vorstellung, die als reine Eselbrücke dienen soll.
Rückgabewerte Wenn sich die Rückgabewerte der überschriebenen und der überschreibenden Methode unterscheiden, erhalten Sie beim Übersetzen ebenfalls eine Fehlermeldung. Das Verhalten ist auf den ersten Blick nicht ganz offensichtlich. Aber bei einer solchen Konstellation sind Sie in einer unzulässigen Zwittersituation, die das auf Stabilität und Zuverlässigkeit ausgerichtete Java-Konzept verhindert. Würde Java beim Überschreiben unterschiedliche Rückgabewerte
170
Polymorphie
gestatten, wäre eine Methode nicht exakt so überschrieben, dass alle Bedingungen der alten Methode von der neuen Methode erfüllt würden. Funktionalität der Superklasse muss explizit auch da funktionieren, wo Funktionalität der Subklasse funktioniert und das könnte nicht gewährleistet werden1. Das Verhalten erscheint vielleicht erst einmal vielleicht lästig, ist aber wieder einer der vielen Aspekte von Java, die die Stabilität und Sicherheit von Java-Programmen sicherstellen.
5.1.2
super und super()
Es gibt diverse Gründe, warum man eine Methode, die bereits in einer Superklasse implementiert war, sowohl in der überschriebenen Variante als auch gleichzeitig in der Originalversion nutzen möchte. Natürlich können Sie in vielen Fällen direkt die Superklasse verwenden, indem Sie eine Instanz davon erzeugen oder – bei Klassenelementen – über den Bezeichner der Superklasse direkt auf ein Element darin zugreifen. Oft ist dieser Weg aber entweder nicht möglich2 oder nicht sinnvoll. Ein indirekter Zugriff über die Subklasse ist dann der Weg zum Ziel.
super Und dazu steht das Schlüsselwort super bereit, das ausschließlich in Instanzmethoden und Konstruktoren verwendet werden kann. Ein Methodenaufruf über super wird in der Hierarchie nach oben zur Superklasse weitergereicht. Schauen wir ein Beispiel mit dem Schlüsselwort super an. Die Klasse A soll diejenige sein, die wir im letzten Beispiel verwendet haben. Neu ist die Klasse D:
1. Beispiel: eine Methode der Superklasse liefert als Rückgabetyp ein int und die überschriebene Methode nicht. Die überschreibende Methode könnte damit nicht so eingesetzt werden wie die ursprüngliche Methode und das widerspricht dem Prinzip der vertragsbasierten Programmierung. 2. Die Superklasse kann zum Beispiel abstrakt sein oder durch Sichtbarkeitsmodifizierer vor einer freien Verwendung geschützt werden.
Objektorientierte Programmierung in Java
171
5 – Erweiterte OO-Techniken public class D extends A { void test(){ System.out.println( "Nicht super, gar nicht klasse."); test1(); } void test1(){ System.out.println("Das ist in test1()." + " Nachfolgend erfolgt der Aufruf von test() " + "aus der Superklasse."); super.test(); } public static void main(String[] args) { new D().test(); } }
Die Methode test() überschreibt wie im vorletzten Beispiel die Methode test() aus der Superklasse A. Intern ruft die reimplementierte Methode test() eine neue Instanzmethode aus der Klasse D mit Namen test1() auf. In dieser Methode erfolgt über super.test(); der Zugriff auf die Methode in der Superklasse. Die Ausgabe wird wie folgt aussehen: Nicht super, gar nicht klasse. Das ist in test1(). Nachfolgend erfolgt der Aufruf von test() aus der Superklasse. Super, echt klasse.
Der Aufruf von super kann übrigens nicht kaskadiert erfolgen. Das bedeutet, so etwas wie super.super ist nicht möglich.
super() In der Form super() kann man beim Überschreiben eines Konstruktors den Konstruktor der Superklasse direkt verwenden. Der Aufruf muss dabei als erste Anweisung im Konstruktor der Subklasse erfolgen! Damit werden also Konstruktoren verkettet. Bei Konstruktoren wird die folgende Syntax verwendet: super(<Parameter>);
172
Polymorphie
Wenn der Konstruktor keine Parameter hat, kann man auch einfach super(); notieren.
5.1.3
Überladen
Java erlaubt ein so genanntes Überladen (engl. Overloading) von Methoden, jedoch kein Überladen von Operatoren1, was sowohl als Vereinfachung als auch als Sicherheitsfeature gelten kann2. Das Methoden-Overloading ist der zweite zentrale Teil der Realisierung des polymorphen Verhaltens von Java. Überladen einer Methode bedeutet, dass bei Gleichheit von Methodenbezeichnern in Superklasse und abgeleiteter Klasse bzw. einer einzigen Klasse einfach mehrere Methoden gleichen Namens parallel vorhanden sind, sofern sich die Parameter signifikant unterscheiden. Signifikant für die Unterscheidung kann die Anzahl der Parameter sein, aber auch bei gleicher Anzahl von Parametern alleine der Parametertyp. Andere Faktoren wie die Namen der Parameter, Modifizierer oder der Rückgabewert sind irrelevant. Das Verfahren muss man wieder vom Standpunkt des Aufrufers einer Methode sehen. Dort muss eindeutig zu erkennen sein, welche Methode gemeint ist. Und beim Aufruf der Methode sind weder Rückgabewert noch Parametername oder Modifizierer unmittelbar zu erkennen (mittelbar teilweise auf Grund der Syntaxregeln schon, aber das spielt hier keine Rolle).
Warum überladen? Es gibt zahlreiche Gründe, warum man Methoden überladen möchte:
쐌 Sie können einem Anwender verschiedene Methoden zu einer verwandten Thematik bereitstellen, die sich im Namen der Methode aber nicht unterscheiden sollen. Damit wird die Anwendung eines Konzepts extrem erleichtert, da sich ein Anwender nicht verschiedene Namen merken muss. Insbesondere ist eine solche Namensgleichheit dann von Vorteil, wenn bereits eine Methode etabliert ist und die Klasse durch eine neue Variante erweitert werden soll. Im Java-API ist die Überladung unzählige Male zu finden. Der polymorphe Charakter von Java wird beson1. Was in einigen OO-Sprachen möglich ist. 2. Es ist extrem riskant und verwirrend, wenn ein Operator in verschiedenen Namensräumen unterschiedliche Bedeutungen hat.
Objektorientierte Programmierung in Java
173
5 – Erweiterte OO-Techniken
ders deutlich, wenn Sie sich die Online-Dokumentation von Java und dort den Index ansehen und einmal nachschauen, wie viele Varianten es von der Methode println() bzw. print() gibt. Für jeden Datentyp als Parameter existiert eine eigene Methode. Bisher haben wir bereits println() in verschiedenen Varianten eingesetzt, ohne dass Sie sich wahrscheinlich Gedanken darüber gemacht haben. 쐌 Das Verfahren des Überladens ist insbesondere bei Konstruktormethoden extrem wichtig, denn diese sind ja im Bezeichner auf den Namen der Klasse festgelegt. Gäbe es kein Überladen, könnte jede Klasse nur genau eine Konstruktormethode besitzen. So aber sind beliebig viele möglich, was auch im Java-API ausgiebig verwendet wird. Beispiele für das Überladen Das folgende Beispiel zeigt, wie Methoden überladen werden. Die Klasse A definiert drei Methoden, die alle drei test als Bezeichner verwenden: public class A { void test() { System.out.println("Ohne Parameter"); } void test(int a) { System.out.println( "Mit int-Parameter. Uebergabewert war " + a); } void test(boolean a) { System.out.println( "Mit boolean-Parameter. Uebergabewert war " + a); } public static void main(String[] args) { A obj = new A(); obj.test(true); obj.test(); obj.test(42); } }
174
Polymorphie
Die drei Methoden unterscheiden sich in der Parameterliste. In der main()-Methode wird zunächst ein Objekt der Klasse A erzeugt und darüber zuerst die Methode mit dem boolean-Parameter, dann die parameterlose Methode und zuletzt die Methode mit dem int-Parameter aufgerufen. Vergegenwärtigen Sie sich noch einmal, dass die Auswahl der richtigen Methode ausschließlich an der Stelle des Aufrufs erfolgt und nur die dort erkennbaren Informationen (also keine Parameternamen oder Rückgabewerte) zur Identifikation bereitstehen.
Überladen von Konstruktoren Überladen macht man sich sehr oft bei Konstruktormethoden zunutze. Diesen fehlt ja jegliche Freiheit bezüglich ihres Namens. Sie können aber beliebig Konstruktoren mit unterschiedlichen Parametersignaturen erzeugen. Konstruktoren dürfen (obwohl sie Methoden sind) keine Rückgabeparameter (nicht einmal die Deklaration als void ist erlaubt) haben, weil sie ausschließlich dazu benutzt werden, die Instanz der Klasse einzurichten. Dazu sind Konstruktoren meist public (öffentlich) und dürfen keine der bei „normalen“ Methoden zu findenden weiteren Modifizierer jenseits der Sichtbarkeit besitzen (das wären native, abstract, static, synchronized, strictfp oder final). In jeder Klasse gibt es zumindest einen Konstruktor. Für Klassen, die selbst keinen Konstruktor definieren, ist dies der so genannte Default-Konstruktor, der automatisch vom Compiler zugeteilt wird und keine Parameter übernimmt. (Wenn die Klasse einen oder mehrere eigene Konstruktoren definiert, wird kein Default-Konstruktor zugeteilt.) Wenn Sie in einer Klasse mehrere Konstruktormethoden erstellen wollen, lässt die Methodenunterschrift wie erwähnt nur die Freiheit, unterschiedliche Parameterlisten anzugeben.
Weiterentwicklung des Bauernhof-API-Projekts Für die Praxis des Überladens von Konstruktoren, aber auch das Überladen und Überschreiben im Allgemeinen, entwickeln wir unser Bauernhof-API-Projekt weiter. Wenn Sie sich das letzte Listing des Programms Bauernhof.java ansehen, finden Sie dort Initialisierungsmethoden (initMilch(), initWolle(), initGeschlecht() und initAlter()). Diese werden im eigentlichen Programm nach dem Erzeugen eines Objekts aufgerufen, um das Objekt zu initialisieren. Wenn Sie sich erinnern, was die Aufgabe eines Konstruktors ist, werden Sie erkennen, dass man diese Initialisierung besser dort vornehmen sollte. Es ist allgemein üblich, offensichtlich beim Erzeugen eines Objekts be-
Objektorientierte Programmierung in Java
175
5 – Erweiterte OO-Techniken
reits mögliche Initialisierungen schon im Konstruktor durchzuführen. Aber der Default-Konstruktor erledigt das natürlich nicht von allein. Er muss ersetzt oder überladen werden, um diese Zusatzfunktionalität zu liefern. Und das machen wir jetzt. Betrachten wir zuerst das modifizierte UML-Klassendiagramm des Bauernhof-API:
Abb. 5.4: Das modifizierte Bauernhof-API
In den Klassen Schwein, Schaf und Kuh werden die Konstruktoren explizit in das Klassendiagramm aufgenommen. Das bedeutet, sie werden dort jeweils definiert. In der Klasse Tier wird eine Variable zufall als protected deklariert und an die Subklassen vererbt.
176
Polymorphie
Die Quelltexte werden nur leicht angepasst. Da nur jeweils Ergänzungen oder das Löschen von Passagen notwendig sind, wird auf den kompletten Abdruck des Quelltextes verzichtet. In der Klasse Tier kommt zuerst eine neue import-Zeile hinzu: import java.util.Random;
In der Klasse müssen Sie nur diese Zeile ergänzen: protected Random zufall = new Random();
In der Klasse Schaf wird der Default-Konstruktor wie folgt ersetzt: public Schaf(){ this.setWolle(zufall.nextInt(16)); }
Beachten Sie den Einsatz von this. Bei Konstruktoren wird sehr oft auf dieses Schlüsselwort zurückgegriffen, um aus dem Konstruktor heraus explizit das damit erzeugte Objekt anzusprechen. In der Klasse Schwein wird der Default-Konstruktor wie folgt ersetzt: public Schwein() { this.setGeschlecht(zufall.nextBoolean()); this.setAlter((byte) zufall.nextInt(7)); }
Bei einem Schwein werden zwei Eigenschaften initialisiert. Und die letzte Erweiterung betrifft die Klasse Kuh, in der der Default-Konstruktor ebenfalls redefiniert wird: public Kuh() { this.setMilch(zufall.nextInt(30)); }
Was hat uns diese Verlagerung nun gebracht? Die Kapselung der Funktionalität wurde verbessert und damit auch die Möglichkeit der Wiederverwendbarkeit. Statt Logik, die explizit einem Objekt bzw. seiner Erzeugung zuzuordnen ist, in eine Anwendung zu verlagern, wurde die Logik an das Objekt (genauer – seine Erzeugung) gekoppelt. Das ist genau das, was OOP auszeichnet.
Objektorientierte Programmierung in Java
177
5 – Erweiterte OO-Techniken
Betrachten wir, wie sich nun die Applikation Bauernhof.class vereinfachen lässt. Sie können alle Initialisierungsmethoden aus der Klasse beseitigen und brauchen (und können) sie damit natürlich nicht mehr in der main()-Methode aufrufen. Die main()-Methode lässt sich also wie folgt vereinfachen: public static void main(String[] args) { Kuh[] rindviecher = new Kuh[5]; for (int i = 0; i < rindviecher.length; i++) { rindviecher[i] = new Kuh(); } Schaf bloeker[] = new Schaf[7]; for (int i = 0; i < bloeker.length; i++) { bloeker[i] = new Schaf(); } Schwein[] grunzer = { new Schwein(), new Schwein() }; Bauernhof schmidt = new Bauernhof(); schmidt.printAlter(grunzer); schmidt.printGeschlecht(grunzer); schmidt.printMilch(rindviecher); schmidt.printWolle(bloecker); }
Nun soll noch als Erweiterung des Bauernhof-API für ein Schaf bei der Erzeugung die Möglichkeit bestehen, dass eine Meldung die Erzeugung begleitet. Die bisherige Vorgehensweise, dies ohne Meldung zu tun, soll jedoch erhalten bleiben. Sie können im parameterlosen Konstruktor nicht entscheiden, wann eine Meldung auszugeben ist und wann nicht, denn Sie können an diesen keine Parameter übergeben. Ein parametrisierter Konstruktor würde hier helfen. Wenn dieser zum Beispiel einen boolean-Übergabewert bekommt, können Sie mit einer if-Kontrollstruktur entscheiden, ob eine Meldung ausgegeben werden soll oder nicht. Nun können Sie aber den bisher definierten Konstruktor nicht einfach wegnehmen. Mehrere Projekte verlassen sich ja bereits auf dessen Existenz. Aber durch die Möglichkeit des Überladens können wir einfach in der Klasse Schaf einen zweiten Konstruktor hinzufügen:
178
Deprecated Elemente public Schaf(boolean meldung){ if (meldung) System.out.println("Schaf geboren"); this.setWolle(zufall.nextInt(16)); }
Dieser lässt sich nun einfach alternativ zum Erzeugen eines Objekts vom Typ Schaf verwenden.
5.2 Deprecated Elemente Der letzte Vorgang – einfach eine Klasse durch Überladen zu erweitern – ist ein zentraler Schlüssel, um Änderungen und Erweiterungen in der OOP durchzuführen. Um Inkompatibilitäten bei einer Weiterentwicklung zu vermeiden, werden einfach neue Methoden ergänzt und die alten beibehalten. Sollte die alte Methode jedoch in Zukunft möglichst nicht mehr weiterverwendet werden, kann man diese als deprecated kennzeichnen. In Java erfolgt das mit einem speziellen Javadoc-Kommentar. Innerhalb eines Javadoc-Kommentars, der der veralteten Methode oder der veralteten Eigenschaft im Quelltext unmittelbar vorangestellt wird, wird ein Feld @deprecated mit einem Hinweistext definiert (siehe Abbildung 5.5).
Abb. 5.5: Der Default-Konstruktor gilt als deprecated
Dies ist dann nicht nur eine Information für das JDK-Tool javadoc, sondern es wird auch vom Compiler respektive Java-IDEs direkt verwendet, die einen Anwender eines deprecated erklärten Elements darauf hinweisen (siehe Abbildung 5.6)
Objektorientierte Programmierung in Java
179
5 – Erweiterte OO-Techniken
Abb. 5.6: Bereits beim Erstellen des Quelltextes weisen einige IDEs einen Programmierer auf veraltete Elemente hin
5.3 Abstrakte Klassen und abstrakte Methoden Schon an diversen Stellen erwähnten wir eine Eigenschaft bzw. einen Modifizierer, der abstract genannt wird. Der Modifizierer kann bei einer Klasse und einer Methode auftauchen. Eine abstrakte Klasse ist im Prinzip eine gewöhnliche Klasse. Signifikante Eigenschaft ist, dass abstrakte Klassen auch Methoden enthalten dürfen, die noch nicht vollständig sind. Damit ist es aber nicht mehr sinnvoll, dass von einer abstrakten Klasse eine Instanz gebildet wird. Sie könnte unfertigen Code enthalten. Eine abstrakte Klasse ist in der Tat nicht instanzierbar, was erhebliche Möglichkeiten zur Strukturierung eines Klassenbaums eröffnet.
5.3.1
Eine abstrakte Klasse vervollständigen
Prinzipiell dienen abstrakte Klassen also dazu, unvollständigen Code zu deklarieren. Es handelt sich dementsprechend bei einer abstrakten Klasse in der Regel um eine Klasse, in der mindestens eine Methode nicht vollständig ist – entweder durch Implementierung einer Schnittstelle, ohne alle dort deklarierten Methoden zu überschreiben (siehe unten) oder indem Sie explizit eine Methode ohne Code in einer Klasse deklarieren. Wenn Sie dies ohne die Kennzeichnung als abstrakt machen, wird durch den Compiler ein Fehler erzeugt oder Sie werden bereits durch eine bessere IDE zur Programmierzeit darauf aufmerksam gemacht.
Abb. 5.7: Entweder bekommt die Methode eine Implementierung oder sie muss als abstract gekennzeichnet werden
180
Abstrakte Klassen und abstrakte Methoden
Eine abstrakte Methode (auch virtuell genannt) besitzt grundsätzlich keine Implementierung. Die Definition der abstrakten Methode in der Klasse erfolgt ausschließlich über die Signatur mit direkt nachgestelltem Semikolon. Dabei muss eine Methode ohne Körper in ihrer Deklaration ebenso mit abstract gekennzeichnet werden wie auch die Klasse selbst.
Abb. 5.8: Eine abstrakte Methode kann nur in einer abstrakten Klasse definiert werden
Abstrakte Klassen werden in einem UML-Diagramm kursiv dargestellt. Allgemein kann in einer abstrakten Klasse bereits vollständig definierter Code unmittelbar (als Klassenelement) verwendet werden. Unvollständiger Code muss in einer Subklasse vervollständigt (überschrieben) werden und erst aus einer vollständigen Subklasse einer abstrakten Klasse lässt sich eine Instanz bilden. Die Subklasse einer abstrakten Klasse muss aber sämtliche (!!) der dort als abstract definierten Methoden vervollständigen. Andernfalls muss die Subklasse selbst wieder als abstrakt deklariert werden. Das ist natürlich möglich. In dem Fall kann aber von dieser Klasse natürlich auch keine Instanz gebildet werden.
Abb. 5.9: Die Subklasse vervollständigt die vererbte Methode nicht und kann so nicht verwendet werden
Die Deklaration einer Klasse als abstrakt bedeutet übrigens nicht, dass vollständiger Code einen Fehler erzeugt. Auch muss eine abstrakte Klasse keinen unvollständigen Code enthalten. Es ist nur wenig sinnvoll, solche Klassen als abstrakt zu kennzeichnen. Es sei denn, Sie wollen explizit verhindern, dass von einer Klasse ein Objekt erzeugt werden kann. Das mag in einigen Fällen sinnvoll sein. Denken Sie an unser Bauerhof-API. Wenn Sie dieses API weitergeben, macht es kaum Sinn, wenn ein Anwender ein Objekt von Tier oder
Objektorientierte Programmierung in Java
181
5 – Erweiterte OO-Techniken SchwSch bildet. Es sind ja nur Hilfsklassen zum Aufbau einer Vererbungsstruktur. Nur Schaf, Kuh oder Schwein sind die Klassen, von denen eine Instanz gebildet werden sollte. Wenn Sie Tier und SchwSch als abstract kennzeichnen,
unterbinden Sie auf elegante Weise die Instanzierung – auch wenn im Prinzip kein unfertiger Code Sie dazu zwingt und es im Grunde sogar einen gewissen „Missbrauch“ der Technik darstellt. Wir sehen bei der Kennzeichnung als abstract übrigens eine der interessantesten Anwendungen der vertragsbasierten Programmierung in Java. Abstrakte Methoden dienen zur Definition einer verbindlichen Sammlung an Methoden und zwingen gewissermaßen die Subklassen dazu, diese Methoden zu implementieren. Dadurch kann frühzeitig in der Entwicklung eines Softwaresystems festgelegt werden, welche Funktionalität bereitstehen wird. Aber erst die Subklassen legen fest, wie die Funktionalität zu implementieren ist.
Ein Beispiel einer abstrakten Klasse Schauen wir uns ein Beispiel an. Die nachfolgende Klasse ist eine einfache abstrakte Klasse mit der Definition einer abstrakten Methode: abstract class A { abstract void test(); }
Die Klasse B soll eine Subklasse von A sein. Darin wird die vererbte Methode test() überschrieben. Damit kann von der Klasse B eine Instanz gebildet werden. public class B extends A { void test() { System.out.println("Vervollstaendigung der " + "abstrakten Methode der Superklasse"); } public static void main(String[] args) { new B().test(); } }
Die Schlüsselwörter final und abstract können übrigens nicht zusammen bei einer Klasse oder einer Methode verwendet werden. Das ist aber offensicht-
182
Abstrakte Klassen und abstrakte Methoden
lich, denn eine finale Klasse kann nicht vererbt werden, eine abstrakte hingegen muss vererbt werden, um davon einen Nutzen zu haben. Einen analogen Widerspruch hätten Sie bei einer Methode. Sie dürfte nicht überschrieben werden und müsste es auf der anderen Seite.
Weiterentwicklung des Bauernhof-API-Projekts Verwenden wir die abstract-Technik, um unser Bauernhof-API weiterzuentwickeln. Diese Weiterentwicklung erfolgt so, dass ein Anwender des API keine Auswirkungen zu befürchten hat. Die Umstrukturierung wird aufgrund der bisherigen Kapselung im Verborgenen erfolgen. Das API wird jedoch qualitativ erheblich verbessert und optimiert. Die Umstrukturierung soll endlich die mehrfach in dem Klassengeflecht vorhandenen Methoden getAlter(), getFutter(), setAlter(), setFutter(), getGewicht() und setGewicht() jeweils an eine Stelle verlagern. Die Methoden getAlter(), getFutter(), setAlter() und setFutter() werden (da für alle Objekte vorhanden) in die Klasse Tier verlagert. Die Implementierung für die Methoden getGewicht() und setGewicht() kommt in die Klasse SchwSch. Beide Klassen werden abstract gesetzt und dadurch lässt sich eine direkte Verwendung dieser Klassen zur Instanzierung verhindern. Damit können die Methoden auch als public vererbt und dennoch erst über eine der gewünschten Klassen verwendet werden. Schauen wir uns zuerst das UML-Klassendiagramm des optimierten API in Abbildung 5.10 an. Bereits im UML-Klassendiagramm sehen Sie die deutliche Vereinfachung bei gleicher Funktionalität. So sieht die Klasse Tier nun aus: package de.rjs.bauernhof.ebene1; import java.util.Random; public abstract class Tier { protected byte alter; protected int futter; protected Random zufall = new Random(); public int getFutter() { return futter; }
Objektorientierte Programmierung in Java
183
5 – Erweiterte OO-Techniken public void setFutter(int futter) { this.futter = futter; } public byte getAlter() { return alter; } public void setAlter(byte alter) { this.alter = alter; } }
Abb. 5.10: Das optimierte API mit abstrakten Klassen
184
Abstrakte Klassen und abstrakte Methoden
Dies ist das Listing der Klasse SchwSch: package de.rjs.bauernhof.ebene2; import de.rjs.bauernhof.ebene1.Tier; public abstract class SchwSch extends Tier { protected int gewicht; public int getGewicht() { return gewicht; } public void setGewicht(int gewicht) { this.gewicht = gewicht; } }
Die extremen Vereinfachungen sehen Sie in den Klassen Kuh, Schwein und Schaf: package de.rjs.bauernhof.ebene2; import de.rjs.bauernhof.ebene1.Tier; public class Kuh extends Tier { private int milch; public Kuh() { this.setMilch(zufall.nextInt(30)); } public int getMilch() { return milch; } public void setMilch(int milch) { this.milch = milch; } }
Objektorientierte Programmierung in Java
185
5 – Erweiterte OO-Techniken package de.rjs.bauernhof.ebene3; import de.rjs.bauernhof.ebene2.SchwSch; public class Schaf extends SchwSch { private int wolle; /** * @deprecated Der Konstruktor ist veraltet. Bitte * den Konstruktor mit einem boolean-Übergabewert * verwenden * */ public Schaf() { this.setWolle(zufall.nextInt(16)); } public Schaf(boolean meldung) { if (meldung) System.out.println("Schaf geboren"); this.setWolle(zufall.nextInt(16)); } public int getWolle() { return wolle; } public void setWolle(int wolle) { this.wolle = wolle; } }
package de.rjs.bauernhof.ebene3; import de.rjs.bauernhof.ebene2.SchwSch; public class Schwein extends SchwSch { private boolean geschlecht; public Schwein() { this.setGeschlecht(zufall.nextBoolean()); this.setAlter((byte) zufall.nextInt(7)); }
186
Schnittstellen public boolean isGeschlecht() { return geschlecht; } public void setGeschlecht(boolean geschlecht) { this.geschlecht = geschlecht; } }
Die explizite Verwendung von abstrakten Methoden soll in der Praxis der Schnittstellen gezeigt werden.
5.4 Schnittstellen Eine Schnittstelle (auch Interface genannt) ist in etwa vergleichbar mit einer abstrakten Klasse, obwohl die Java-Sprachreferenz streng genommen von einem eigenen Referenztyp spricht. Sie kann ausschließlich abstrakte Methoden und Konstanten (sowie innere Klassen und Schnittstellen) enthalten. Eine Schnittstelle ist also eine Sammlung von Methodennamen ohne konkrete Implementierung und/oder mit einer Reihe von Konstanten. Dies charakterisiert eigentlich schon alles, wenn man den Zwang der vertragsbasierten Programmierung ergänzt, den die Verwendung einer Schnittstelle ausübt.
5.4.1
Wozu Schnittstellen?
Schnittstellen ermöglichen ähnlich wie abstrakte Klassen das Erstellen von Pseudoklassen, die ganz aus abstrakten Methoden zusammengesetzt sind. Neben der Eigenschaft als Alternative zur Mehrfachvererbung werden Schnittstellen (gerade durch die Eigenschaft, keine konkrete Definition zu besitzen) dazu benutzt, eine bestimmte Funktionalität zu definieren, die in mehreren Klassen benutzt werden soll, aber wo die genaue Umsetzung einer Funktionalität noch nicht sicher ist. Sie wird erst später in der auf die Schnittstelle aufbauenden Klasse realisiert. Sofern Sie derartige Methoden in einer Schnittstelle unterbringen, können Sie gemeinsame Verhaltensweisen definieren und die spezifische Implementierung dann den Klassen selbst überlassen. Daher liegt die Verantwortung für die Spezifizierung von Methoden dieser Implementierungen, ähnlich wie bei abstrakten Klassen, immer bei den Klassen, die eine Schnittstelle implementieren.
Objektorientierte Programmierung in Java
187
5 – Erweiterte OO-Techniken
Schnittstellen beinhalten bei ihrer Verwendung wie gesagt ein Zwangsverfahren. Durch die Implementierung von Schnittstellen muss jede nichtabstrakte Klasse alle in der Schnittstelle deklarierten Methoden implementieren! Das ist analog der Verwendung von abstrakten Klassen.
Mehrfachvererbung light Schnittstellen gelten als eine Art Ersatz von Mehrfachvererbung, denn per Kommata getrennt können in eine Klasse beliebig viele Schnittstellen eingebunden werden. Man redet von „leichter Mehrfachvererbung“. Schnittstellen werden in UML-Diagrammen als Klassen dargestellt, die das Stereotyp "interface" tragen. Die Implementierung eines Interfaces durch eine andere Klasse wird durch einen gestrichelten Vererbungspfeil dargestellt. Ein Kreis symbolisiert die Bereitstellung eines Interfaces.
5.4.2
Erstellung einer Schnittstelle
Die Syntax zur Erstellung einer Schnittstelle und der konkrete Erstellungsprozess sind dem Vorgehen bei (abstrakten) Klassen sehr ähnlich. Es gibt hauptsächlich den Unterschied, dass keine Methode in der Schnittstelle einen Körper haben darf und keine Variablen deklariert werden dürfen, die nicht als Konstanten dienen. Die Deklaration einer Schnittstelle erfolgt mit folgender Syntax: [public] interface [extends <SchnittstellenListe>]
wobei alles in eckigen Klammern Geschriebene optional ist. Statt class finden Sie hier interface als Kennzeichen. Schnittstellen können per Voreinstellung von allen Klassen im selben Paket implementiert werden (freundliche Einstellung). Damit verhalten sie sich wie freundliche Klassen und Methoden. Indem Sie Ihre Schnittstelle explizit als public deklarieren, ermöglichen Sie es – wie auch bei Klassen und Methoden – den Klassen und Objekten außerhalb eines gegebenen Pakets, diese Schnittstelle zu implementieren. Analog den public-Klassen müssen öffentlich deklarierte Schnittstellen zwingend in einer Datei namens .java definiert werden. Andere Zugriffsmodifizierer wie public sind
188
Schnittstellen
bei Schnittstellen nicht erlaubt! Die Regeln für die Benennung von Schnittstellen sind dieselben wie für Klassen. Das ist alles vollkommen analog zu dem Verfahren bei Klassen. Das unterstreicht auch die Übersetzung durch den Compiler, denn aus jeder Schnittstellendeklaration in einer .java-Datei wird ein .class-File.
Ein Schnittstellenbeispiel Eine einfachste Schnittstelle ohne irgendwelche Funktionalität sieht so aus: interface A { }
Vererbung bei Schnittstellen Java-Schnittstellen können gemäß dem OOP-Konzept der Vererbung auch andere Schnittstellen erweitern, um somit zuvor geschriebene Schnittstellen weiterentwickeln zu können. Die neue Sub-Schnittstelle erbt dabei auf die gleiche Art und Weise wie bei Klassen die Eigenschaften der Super-Schnittstelle(n). Vererbt werden alle Methoden und statischen Konstanten der Super-Schnittstelle. Schnittstellen können andere Schnittstellen erweitern, indem sie per extends an eine andere Schnittstelle angehängt werden. Dabei lassen sich aber auch – im Gegensatz zu normalen Klassen – mehrere Super-Schnittstellen angeben. interface A{ int z=1; String str = "Text."; void test(); } interface B { int y=2; int test(int i); } interface C extends A, B{ }
Objektorientierte Programmierung in Java
189
5 – Erweiterte OO-Techniken
Implementieren oder wieder als abstrakt erklären Weil Schnittstellen allgemein keine konkreten Methoden definieren dürfen, dürfen auch Sub-Schnittstellen keine Methoden der Super-Schnittstellen definieren. Stattdessen ist das die Aufgabe jeder Klasse, die die abgeleitete Schnittstelle verwendet. Die Klasse muss sowohl die in der Sub-Schnittstelle deklarierten Methoden als auch alle Methoden der Super-Schnittstellen definieren! Wenn Sie also eine erweiterte Schnittstelle in einer Klasse implementieren, müssen Sie sowohl die Methoden der neuen als auch die der alten Schnittstelle implementieren.
Der Körper einer Schnittstelle Da eine Schnittstelle neben Typdefinitionen nur abstrakte Methoden und Konstanten (als final deklarierte Variablen) enthalten darf, können im Körper einer Schnittstelle zwar keine bestimmten Implementierungen spezifiziert werden, aber ihre Eigenschaften lassen sich dennoch festlegen. Ein großer Teil der Vorzüge von Schnittstellen resultiert aus der Fähigkeit, Methoden deklarieren zu können. Die Verwendung des Schlüsselworts public bei Methodendeklarationen in Schnittstellen ist zwar bei der Deklaration einer Methode möglich, aber da alle Methoden in Schnittstellen standardmäßig public sind, kann man diesen Modifizierer weglassen. Theoretisch kann man Methoden auch als abstract kennzeichnen, aber das ist ebenfalls überflüssig, weil alle Methoden in Schnittstellen abstrakt sind. Es dient wie bei public höchstens der besseren Lesbarkeit. Die offizielle Java-Referenz rät sogar von der expliziten Deklaration als public und abstract ab! Die anderen potenziellen Methodenmodifizierer (native, static, synchronized, strictfp, final, private oder protected) dürfen bei der Deklaration einer Methode in einer Schnittstelle nicht verwendet werden. Auch bei Schnittstellen selbst kann bei der Deklaration vor dem Schlüsselwort interface der Modifizierer abstract gesetzt werden. Das ist aber – genau wie bei den Methoden – nur der besseren Lesbarkeit wegen in manchen Fällen sinnvoll und im Allgemeinen überflüssig. Variablen in Schnittstellen werden finale, globale Felder für die Klasse sein. Alle Felder, die in einer Schnittstelle deklariert werden, sind unabhängig vom Modifizierer, der bei der Deklaration des Felds benutzt wurde, immer public,
190
Schnittstellen final und static. Sie müssen das nicht explizit in der Felddeklaration ange-
ben, obwohl es der besseren Lesbarkeit halber manchmal sinnvoll ist. Es handelt sich also immer um öffentliche Klassenkonstanten. Weil alle Felder final sind, müssen sie in der Schnittstelle unbedingt initialisiert werden, wenn sie von der Schnittstelle selbst deklariert werden. Selbstverständlich können Methoden und Konstanten zusammen in einer Schnittstelle deklariert werden. Viel von der Leistungsfähigkeit von Schnittstellen basiert auch auf der gemeinsamen Deklaration in einer Schnittstelle. Wenn Sie Konstanten einer Schnittstelle verwenden wollen, erkaufen Sie das mit dem Zwang, alle dort vorhandenen Methoden unbedingt implementieren zu müssen. Eine autoritäre, aber geniale Führung zum Ziel.
5.4.3
Verwenden von Schnittstellen
Grundsätzlich kann eine Klasse, die eine Schnittstelle implementiert, nicht zur Instanzierung verwendet werden, ehe nicht alle in der Schnittstelle definierten Methoden implementiert worden sind. Eine Schnittstellenmethode selbst könnte aber sowieso nicht verwendet werden, bis sie in der Klasse implementiert worden ist. Das ist zwangsläufig, da eine Schnittstellenmethode ja keinerlei Körper haben darf. Eine Methodendeklaration in einer Schnittstelle legt aber das Verhalten einer Methode insoweit fest, als der Methodenname, der Rückgabetyp und die Parametersignatur definiert werden. Die Implementierung von Schnittstellen in Klassen erfolgt über die implements-Anweisung. Wenn mehrere Schnittstellen in einer Klasse implementiert werden sollen, werden sie durch Kommata getrennt angehängt. Beispiele: class A implements B class C implements D, E
5.4.4
Implementieren von Methoden einer Schnittstelle
Wenn eine Schnittstellenmethode in einer Klasse überschrieben wird, dann gibt es mehrere Aspekte, die geändert werden können. Was sich an der Methode nie verändern darf, ist der Methodenname. Aber auch andere Faktoren unterliegen bezüglich einer Veränderung strengen Regeln. Wie wir schon gesehen haben, sind alle in einer Schnittstelle deklarierten Methoden als Grundeinstellung mit dem Zugriffslevel public ausgestattet (unab-
Objektorientierte Programmierung in Java
191
5 – Erweiterte OO-Techniken
hängig von der expliziten Auszeichnung). Eine solche Methode kann nicht so implementiert werden, dass der Zugriff auf sie weiter beschränkt wird1. Deshalb müssen alle in einer Schnittstelle deklarierten und in einer Klasse überschriebenen Methoden mit dem Zugriffsmodifizierer public versehen werden. Von den übrigen Modifizierern, die auf Methoden angewendet werden können, dürfen nur native und abstract auf solche Methoden angewendet werden, die ursprünglich in einer Schnittstelle deklariert wurden. Schnittstellenmethoden können eine Liste mit Parametern definieren, die an die Methode weitergegeben werden müssen. Wenn Sie in der Klasse eine neue Methode mit dem gleichen Namen, jedoch einer anderen Parameterliste deklarieren, wird wie allgemein üblich die in der Schnittstelle deklarierte Methode überladen und nicht überschrieben. Dies ist zwar nicht falsch. Aber dann muss noch zusätzlich die abstrakte Methode überschrieben werden. Denn wir hatten ja schon festgehalten, dass jede Methode in einer Schnittstelle überschrieben werden muss. Außer, Sie erklären sie wieder für abstrakt. Das bedeutet, dass sich für eine Redefinition zwar die Namen von Variablen ändern dürfen, nicht aber deren Anordnung und Typ. Da der Körper einer Schnittstellenmethode leer ist, muss als wesentliche Aufgabe bei der Implementierung einer solchen Methode in einer Klasse ein Körper für die ursprünglich in der Schnittstelle deklarierten Methoden erstellt werden. Wie Sie konkret eine Schnittstellenmethode nun implementieren, hängt von der Aufgabe ab, die die jeweilige Methode erfüllen soll. Eine Schnittstelle stellt zwar sicher, dass Methoden in einer nichtabstrakten Klasse definiert und entsprechende Datentypen zurückgegeben werden. Ansonsten werden von ihr aber keine weiteren Restriktionen oder Begrenzungen für die Körper der Methoden in den Klassen festgelegt. Die Erstellung eines Methodenkörpers, der nur aus geöffneten und geschlossenen geschweiften Klammern besteht, reicht beispielsweise aus, um die Bedingung einer Methode zu erfüllen, deren Rückgabetyp void ist. Ansonsten genügt eine return-Anweisung, die einen geforderten Datentyp zurückliefert. Das ist aber selten sinnvoll, denn der Ersteller einer Schnittstelle hat sich ja etwas dabei gedacht, eine bestimmte Behandlung von Aufgaben vorzuschreiben.
1. Eine Regel, die Ihnen vom Überschreiben bekannt ist.
192
Schnittstellen
Felder in einer Schnittstelle Die Felder einer Schnittstelle müssen sowohl statisch als auch final sein. Sie stehen bei der Implementierung der Schnittstelle in der Klasse als Erbe zur Verfügung und können über eine Instanz der Klasse – wenn diese vervollständigt wurde – verwendet werden. Aber der Zugriff auf ein Feld einer Schnittstelle kann auch direkt über die Benutzung der standardisierten Punktschreibweise erfolgen, ohne eine Instanz zu erzeugen – entweder über die (möglicherweise sogar noch abstrakte) vorangestellte Klasse, die die Schnittstelle implementiert oder die Schnittstelle selbst: .
Weiterentwicklung des Bauernhof-API-Projekts Verwenden wir nun eine Schnittstelle, um unser Bauernhof-API weiterzuentwickeln. Diese Weiterentwicklung soll eine echte Ergänzung darstellen und einen Zwang in die Subklassen von Tier implementieren. Nach einer gewissen Zeit des Betriebs unseres Bauerhofprogramms tritt der Kunde mit dem Wunsch einer Erweiterung der Funktionalität an uns heran. Seine Nachbarn haben sich über den Lärm beschwert, den die Tiere auf dem Bauernhof produzieren. Um die Tiergeräusche dokumentieren zu können, soll in dem Bauernhof-API für jedes Tier eine Methode implementiert werden, die dessen typisches Geräusch abbildet. Nun gibt ein Schaf andere Geräusche von sich als ein Schwein und eine Kuh äußert sich wieder in einer anderen Stimmlage. Insbesondere ist es schwer, das genaue Geräusch eines beliebigen Tiers oder gar eines SchwSchs festzulegen1. Es ist also klar, dass jedes Tier ein Geräusch von sich gibt. Aber es ist nicht festzulegen, wie es sich bei einem allgemeinen Tier äußert. Was bedeutet das? In einer Superklasse wird eine abstrakte Festlegung einer Methode erfolgen, die erst in einer Subklasse implementiert werden kann. Und dieses Mal soll eine Schnittstelle für die Deklaration der abstrakten Methode herangezogen werden. In der Schnittstelle wird die Methode String lautGeben() eingeführt, die in den Klassen Kuh, Schaf und Schwein implementiert werden muss. Damit werden die Klassen Tier und SchwSch auch nicht mehr „freiwillig“ abstrakt sein, sondern durch die Implementierung von der Schnittstelle in Tier sogar 1. ;-)
Objektorientierte Programmierung in Java
193
5 – Erweiterte OO-Techniken
dazu gezwungen (es sei denn, sie implementieren selbst die Methode lautGeben(), was ja explizit so nicht sein soll). In Abbildung 5.11 sehen Sie das UML-Klassendiagramm. Dabei wird in dem Diagramm die Methode lautGeben() in den Klassen Kuh, Schaf und Schwein nicht explizit aufgeführt. Da diese jedoch nicht als abstrakt gekennzeichnet sind, ist es implizit zwingend, dass sie die vererbte Methode implementieren.
Abb. 5.11: Das UML-Klassendiagramm mit der Schnittstelle
Die Schnittstelle Geraeusch sieht so aus: package de.rjs.bauernhof.ebene1; public interface Geraeusch { public abstract String lautGeben(); }
194
Schnittstellen
Die Klassensignatur von Tier wird wie folgt erweitert: public abstract class Tier implements Geraeusch
Sonst muss in der Klasse keine Änderung vorgenommen werden. Die Klasse SchwSch bleibt unverändert. Dafür müssen die Klassen Kuh, Schaf und Schwein die Methode lautGeben() implementieren. Die Klassen sehen nun wie folgt aus: package de.rjs.bauernhof.ebene2; import de.rjs.bauernhof.ebene1.Tier; public class Kuh extends Tier { private int milch; public Kuh() { this.setMilch(zufall.nextInt(30)); } public int getMilch() { return milch; } public void setMilch(int milch) { this.milch = milch; } public String lautGeben() { return "Muh"; } }
package de.rjs.bauernhof.ebene3; import de.rjs.bauernhof.ebene2.SchwSch; public class Schaf extends SchwSch { private int wolle; /** * @deprecated Der Konstruktor ist veraltet. * Bitte den Konstruktor mit einem * boolean-Übergabewert verwenden
Objektorientierte Programmierung in Java
195
5 – Erweiterte OO-Techniken * */ public Schaf() { this.setWolle(zufall.nextInt(16)); } public Schaf(boolean meldung) { if (meldung) System.out.println("Schaf geboren"); this.setWolle(zufall.nextInt(16)); } public int getWolle() { return wolle; } public void setWolle(int wolle) { this.wolle = wolle; } public String lautGeben() { return "Maaeeeeeeeeehh"; } }
package de.rjs.bauernhof.ebene3; import de.rjs.bauernhof.ebene2.SchwSch; public class Schwein extends SchwSch { private boolean geschlecht; public Schwein() { this.setGeschlecht(zufall.nextBoolean()); this.setAlter((byte) zufall.nextInt(7)); } public boolean isGeschlecht() { return geschlecht; } public void setGeschlecht(boolean geschlecht) { this.geschlecht = geschlecht; }
196
Exception-Handling public String lautGeben() { return "Grunz"; } }
In der Klasse Bauernhof können jetzt die spezialisierten Methoden lautGeben() für jedes konkrete Tier (eine Kuh, ein Schaf oder ein Schwein) verwendet werden.
5.5 Exception-Handling In jeder Form der Programmierung muss man mit Fehlersituationen umgehen. Dabei muss man verschiedene Situationen unterscheiden:
쐌 Typografische Fehler beim Schreiben des Quelltextes 쐌 Syntaktische Fehler beim Schreiben des Quelltextes 쐌 Programmfehler zur Laufzeit, die auf logische Fehler im Programmaufbau zurückzuführen sind 쐌 Programmfehler und unklare Situationen zur Laufzeit, die auf äußere Umstände zurückzuführen sind Die Bereinigungen der ersten drei Punkte ist hauptsächlich das, was man unter Debugging versteht, wobei streng genommen die ersten beiden Punkte bereits der Compiler abfängt. Die Behandlung des vierten Punkts jedoch zählt in Java im Wesentlichen zum so genannten Exception-Handling bzw. der Ausnahmebehandlung. Und dies ist nicht nur eine der wichtigsten Java-Techniken, sondern auch explizit eine vertragsbasierte Programmierung.
5.5.1
Was sind Ausnahmen?
Mittels des Ausnahmekonzepts kann Java zwei seiner Grundprinzipien – maximale Sicherheit und Stabilität – gewährleisten. Eine Ausnahme ist eine Unterbrechung des normalen Programmablaufs aufgrund einer besonderen Situation, die eine isolierte und unverzügliche Behandlung notwendig macht. Der normale Programmablauf wird erst fortgesetzt, wenn diese Ausnahme behandelt wurde1. Ein klassisches Beispiel, das eine solche Situation provoziert, ist 1. Etwas sehr vereinfachend können Sie sich die Situation wie einen Interrupt des Betriebssystems vorstellen, also eine Art Interrupt des Programms.
Objektorientierte Programmierung in Java
197
5 – Erweiterte OO-Techniken
der Versuch, auf einen Wechseldatenträger zuzugreifen, in dem kein Medium eingelegt ist. Eine Java-Klasse, die dafür einen Zugriffsmechanismus zur Verfügung stellt, sollte vor einem Zugriff auf einen Wechseldatenträger überprüfen, ob ein Medium eingelegt ist, und andernfalls mit einer Ausnahme auf den Versuch reagieren. Diese Ausnahme gibt Informationen zurück, welcher Fehler genau vorliegt, und aufgrund dieser Information kann der Programmierer Gegenmaßnahmen ergreifen. Ausnahmebehandlung ist jedoch nicht auf reine Fehler im engeren Sinne begrenzt, sondern beschäftigt sich mit jeder Form einer kritischen Situation zur Laufzeit eines Programms, die eine unverzügliche Reaktion erfordern und mit Gegenmaßnahmen behandelt werden kann (so genannte auffangbare Laufzeitfehler oder trappable Errors). Java verfügt dabei über ein voll in die gesamte Plattform integriertes Konzept zum Umgang mit auffangbaren Laufzeitfehlern.
Warum ein Ausnahmekonzept? Ein Ausnahmekonzept ist in einer Programmiersprache nicht unabdingbar. Immerhin hatten ältere Programmiersprachen so ein Konzept ja nicht implementiert. Sie können auch mit selbst programmierten Kontrollmechanismen jeden Fehler in einem Programm auffangen, den Sie als potenzielle Fehlerquelle erkennen. Aber da haben wir den entscheidenden Schwachpunkt – Sie müssen die potenzielle Fehlerquelle erkennen, was oft nahezu unmöglich ist. Gerade in komplexen Programmen können Sie nie alle kritischen Zusammenhänge (auch aufgrund von Verschachtelungen) überblicken. Es werden immer Fehlerquellen da sein, die an vorher kaum beachteten Stellen auftreten. Außerdem ist es sehr viel Arbeit, bei jeder Anweisung im Quelltext zu überlegen, ob da eine Gefahr lauert, und diese dann sicher abzufangen. Es ist sogar so, dass Sie in der überwiegenden Anzahl von auffangbaren Fehlern das Rad neu erfinden, denn es gibt sehr oft schon Standardmaßnahmen. Zu guter Letzt setzt diese individuelle Technik der Laufzeitfehlerbehandlung oft voraus, dass der Fehler selbst nicht eintritt, sondern vorher erkannt und umgebogen wird. Der vollintegrierte automatische Mechanismus der Ausnahmenbehandlung zum Umgang mit auffangbaren Fehlern in Java ist viel mächtiger und dennoch mit weniger Aufwand verbunden als eine individuelle Fehlerbehandlung. Er erlaubt sowohl eine Fehlerbehandlung „vor Ort“ als auch eine globale Fehlerbehandlung an einer zentralen Stelle im Programm, an die Ausnahmeobjekte
198
Exception-Handling
weitergereicht werden. Dabei ist es von besonderer Bedeutung, dass das JavaKonzept ein Programm bei einer solchen Ausnahmesituation dennoch stabil weiterlaufen lässt! Zumindest, wenn es einen weiteren sinnvollen Ablauf gibt.
5.5.2
Die Klassen Throwable, Error und Exception
Eine Ausnahme ist in Java ein Objekt, das eine Instanz der Klasse Throwable oder einer seiner Subklassen ist. Deren direkte Subklassen, Error und Exception, teilen Ausnahmen in zwei Zweige, die Ausnahmen in echte Fehler und zu behandelnde Situationen aufspalten. Einerseits wird jedes Objekt vom Typ einer Ausnahmeklasse vom Java-Laufzeitsystem als Ausnahme identifiziert und die dazugehörenden Mechanismen werden in Gang gesetzt. Andererseits erlaubt es dieses Konzept Programmierern, leicht eigene Ausnahmen zu definieren, indem einfach eine Unterklasse von der Klasse Throwable oder einer bereits vorhandenen Ausnahmeklasse abgeleitet wird. Dabei genügt es für verschiedene Situationen bereits, einfach eine Klasse als Subklasse einer Ausnahmeklasse zu definieren, ohne konkrete Funktionalität zu ergänzen1. Beispiel: public class A extends Throwable { }
5.5.3
Auswerfen und Dokumentieren einer Ausnahme
Eine so als Subklasse erstellte eigene Ausnahmeklasse oder eine Standardausnahmeklasse wird innerhalb von Methoden mit der Anweisung throw ausgeworfen. Das ist eine Sprunganweisung mit Rückgabewert – eben das Ausnahmeobjekt. Verwandt ist die Situation mit einer return-Ausweisung. Das Konzept ist aber mehr – eine Methode, die eine Ausnahme auswirft, muss dies mit Ausnahme von Exceptions, die von RuntimeException oder Error stammen, dem Aufrufer der Methode in der Methodendeklaration mitteilen (ein Kontrakt). Dies geschieht mit dem Schlüsselwort throws, was nicht mit throw verwechselt werden darf. Beispiel (die Klasse A sei die oben erzeugte Subklasse von Throwable): 1. Es gibt nur sehr wenige Situationen, in denen die Erzeugung einer Subklasse ohne Erweiterung der Funktionalität ihrer Superklasse sinnvoll ist. Dies ist aber so ein Fall, wo das sinnvoll sein kann. Beachten Sie dazu die Weiterentwicklung unseres Bauernhof-API.
Objektorientierte Programmierung in Java
199
5 – Erweiterte OO-Techniken public class B { public void mMethEx () throws A { System.out.println("Gleich kommt eine Ausnahme"); throw new A(); } }
Mehrere potenzielle Ausnahmen werden in der Methodensignatur mit Kommata getrennt. Dieser über throws eingeleitete Kontrakt zwingt einen Programmierer dazu, alle möglichen Ausnahmen einer Methode abzufangen, bevor er sie verwenden kann. Diese Beschreibung der Gefahr ist ein Vertrag zwischen der Methode und demjenigen, der die Methode verwendet. Die Beschreibung enthält Angaben über die Datentypen der Argumente einer Methode und die allgemeine Semantik. Außerdem werden die gefährlichen Dinge, die bei der Methode auftreten können, spezifiziert. Als Anwender der Methode können Sie sich darauf verlassen, dass diese Angaben die Methode korrekt charakterisieren. Wenn Sie sich die Dokumentation der Standardpakete von Java ansehen, werden Sie erkennen, dass eine große Anzahl von Methoden dort mit throws gekennzeichnet sind. Sie werden diese Methoden nur dann verwenden können, wenn Sie diese Ausnahmen behandeln.
5.5.4
Ausnahmen behandeln
Da Java einen Mechanismus zur standardisierten Erzeugung von Ausnahmen bereitstellt, ist es nahe liegend, dass es auch einen standardisierten Mechanismus zu deren Auswertung und Behandlung besitzt. Man muss dabei zwei Szenarien unterscheiden.
Globales versus lokales Exception-Handling An Stellen, an denen Sie eine Methode mit potenziellen Ausnahmen verwenden wollen, müssen Sie sich darum kümmern. Sie müssen die dokumentierten Ausnahmen entweder selbst abfangen oder selbst mittels der throws-Erweiterung in der aufrufenden Methode weiterreichen. Wenn nun eine mit der throws-Erweiterung gekennzeichnete Methode aufgerufen wird, was passiert dann? In diesem Fall wird die aufrufende Methode nach einer expliziten Ausnahmebehandlung durchsucht (wie die aussieht, folgt
200
Exception-Handling
gleich). Enthält die aufrufende Methode eine direkte Ausnahmebehandlung, wird mit dieser die Ausnahme bearbeitet. Das ist ein lokales Exception-Handling. Ist dort keine direkte Routine zur Ausnahmebehandlung vorhanden, wird deren aufrufende Methode durchsucht und so fort. Das Spiel geht so lange weiter, bis eine Ausnahmebehandlungsmethode gefunden ist oder die oberste Ebene des Programms erreicht ist. Die Ausnahme wird von der Hierarchieebene, in der sie aufgetreten ist, jeweils eine Hierarchieebene weiter nach oben gereicht. So etwas wird dann globales Exception-Handling genannt. Sofern eine Ausnahme nirgends aufgefangen wird, bricht der Java-Interpreter normalerweise die Ausführung des Programms ab. Dieses Weiterreichen der Behandlung über verschiedene Hierarchieebenen erlaubt sowohl die unmittelbare Behandlung ganz nahe am Ort des Problems als auch eine entfernte Behandlung, wenn dies sinnvoll ist – etwa in einer Struktur, die mehrere potenzielle Probleme umgibt.
Welche Ausnahmen müssen zwingend aufgefangen werden? Natürlich müssen nicht alle denkbaren Fehler und Ausnahmen explizit von einem Programmierer selbst aufgefangen werden. Die Klasse Throwable im Paket java.lang besitzt, wie oben erwähnt, zwei große Subklassen im gleichen Paket: die Klassen Exception und Error. In den beiden Ausnahmeklassen sind die wichtigsten Ausnahmen und Fehler der Java-Laufzeitbibliothek bereits enthalten. Diese beiden Klassen bilden zwar zwei getrennte Hierarchien, werden aber ansonsten gleichwertig als Ausnahmen behandelt. Die Ausnahmen der Klasse RuntimeException und ihrer Subklassen sowie der Klasse Error und Subklassen müssen Sie nicht extra abfangen oder dokumentieren. Dies geschieht automatisch durch das Java-Laufzeitsystem. Wenn keine Behandlung durch Sie erfolgt, wird die Ausnahme auf oberster Ebene des Java-Laufzeitsystems behandelt, in der Systemausgabe erfolgt eine Meldung und ein Programm wird beendet. In die Ausnahmentypen dieser Klassen fallen unter anderem Situationen wie Speicherplatzmangel, Datenfeldprobleme oder mathematische Fehlersituationen. Wenn Sie sich die Dokumentation von Java anschauen, werden Sie dort Hunderte von Standardausnahmen entdecken. Sie werden diese Standardproblemfälle in vielen Fällen in einer Methodensignatur auch nicht mehr explizit auflisten, denn damit zwingen Sie einen Aufrufer der Methode, diese irgendwie zu handhaben (was sonst automatisch über die JavaUmgebung behandelt wird).
Objektorientierte Programmierung in Java
201
5 – Erweiterte OO-Techniken
5.5.5
Explizites Ausnahmen-Handling
Die explizite Behandlung einer Ausnahme erfolgt in Java in einer umgebenden try-catch-Struktur. Das sieht schematisch so aus: try { // Innerhalb des try-Blocks werden kritische // Aktionen ausgeführt, die Ausnahmen erzeugen können. } catch (Exception e) { // Behandlung der Ausnahme }
Der try-Block Kritische Aktionen in einem Java-Programm werden immer innerhalb eines try-Blocks notiert. Der Begriff „try“ sagt bereits sehr treffend, was dort passiert. Es wird versucht, den Code innerhalb des try-Blocks auszuführen. Wenn ein Problem auftauchen sollte (sprich, es wird eine Ausnahme ausgeworfen), wird dieses sofort entsprechend im passenden catch-Block gehandhabt (sofern vorhanden) und alle nachfolgenden Schritte im try-Block werden nicht mehr durchgeführt. Am Ende eines try-Blocks können beliebig viele catch-Klauseln mit unterschiedlichen Ausnahmen (möglichst genau die Situation beschreibend) stehen. Sie werden einfach nacheinander notiert, wobei es nicht möglich ist, eine Subklasse einer vorher bereits aufgefangenen Ausnahmeklasse zu notieren. Dies würde unerreichbaren Code bewirken und das lässt das Java-Konzept nicht zu. Ein Verschachteln von try-catch-Strukturen ist ebenfalls möglich, wobei dann auch außerhalb angesiedelte catch-Blöcke im Inneren nicht explizit aufgefangene Ausnahmen auffangen können. Damit lassen sich unterschiedliche Arten von Ausnahmen auch verschiedenartig – und damit sehr qualifiziert – handhaben. Anstatt alle möglichen Ausnahmen explizit aufzulisten, die eventuell erzeugt werden könnten, können Sie auch einfach einen etwas allgemeinen Ausnahmetyp auflisten (wie beispielsweise java.lang.Exception). Damit würde jede Ausnahme abgefangen, die aus java.lang.Exception abgeleitet wurde. Das lässt dann aber keine qualifizierte (das heißt der Situation genau angepasste) Reaktion zu.
202
Exception-Handling
Falls nirgendwo eine Behandlung erfolgt, wird sich letztendlich das System selbst der Ausnahme annehmen und entweder eine Fehlermeldung ausgeben und/oder das Programm beenden.
Der catch-Teil Sie haben nun eine Ausnahme in einem catch-Teil aufgefangen. Dort erfolgt die eigentliche Behandlung der Ausnahme. Wir wissen bereits, dass der catchTeil unter gewissen Umständen (nicht immer) optional ist und auch weggelassen werden kann. Genauso ist es möglich, mehrere catch-Anweisungen zu benutzen, die dann sequenziell überprüft werden, ob sie für die aufgetretene Ausnahme zuständig sind (wenn keine entsprechende catch-Anweisung gefunden wird, wird die Ausnahme an den nächsthöheren Programmblock weitergereicht). Aber was tun Sie damit konkret? Nun, das bleibt vollkommen Ihnen überlassen. Diese Aussage soll deutlich machen, dass es Ihrem Konzept überlassen ist, eine Ausnahme so zu behandeln, wie es für das Programm sinnvoll erscheint. Wir werden unter anderem eine potenzielle Behandlung durchspielen – die Ausgabe einer Fehlermeldung. Und es muss noch erklärt werden, wie Sie überhaupt an die Ausnahme herankommen. Schauen wir uns nochmals die Syntax einer beispielhaften catch-Klausel an: catch (ArithmeticException m) { }
In den runden Klammern nach dem Schlüsselwort catch steht ein Verweis auf den Typ der Ausnahme, der in der betreffenden catch-Klausel behandelt werden soll. Sie können dort eine der unzähligen Standardausnahmen von Java verwenden oder auch selbst definierte Ausnahmen. Das Ausnahmeobjekt, das damit übergeben wird, besitzt einige Methoden, die Sie dann in der Behandlung nutzen können – etwa die Methode getMessage(). Diese Methode gibt die Fehlermeldungen der Ausnahme zurück. Sie wird von der Klasse Throwable (der Superklasse aller Ausnahmen) definiert und ist daher in allen Exception-Objekten vorhanden. Wir nehmen ein Beispiel mit der potenziellen Division durch einen Wert 0 und packen es in eine try-catch-Struktur. Daran sieht man, wie so eine Ausnahme behandelt werden kann. Dabei seien a, b und teiler irgendwelche int-Werte, die irgendwoher Werte zugewiesen bekommen:
Objektorientierte Programmierung in Java
203
5 – Erweiterte OO-Techniken try { a=b/teiler; } catch (ArithmeticException m) { System.out.println( "Fehler bei der Berechnung: " + m.getMessage()); }
Die finally-Klausel Die hinter dem letzten catch-Block optional zu notierende finally-Anweisung erlaubt die Abwicklung wichtiger Abläufe, bevor die Ausführung des gesamten try-catch-finally-Blocks unterbrochen wird. Unabhängig davon, ob innerhalb des try-Blocks eine Ausnahme auftritt oder nicht, werden die Anweisungen in dem Block finally ausgeführt. Tritt eine Ausnahme auf, so wird der jeweilige catch-Block ausgeführt und im Anschluss daran erst der Block hinter der finally-Anweisung. Nun sollte aber anhand der bisherigen Ausführungen aufgefallen sein, dass eine Ausnahme ein Programm in der Regel nicht abbricht und auch Anweisungen hinter der gesamten try-catch-Struktur auf jeden Fall ausgeführt werden, wenn eine Ausnahme behandelt wurde. Wozu dann die finally-Klausel? Könnte man nicht alle auf jeden Fall auszuführenden Anweisungen außerhalb der gesamten Struktur notieren? Oft ist das auch wirklich möglich. Es gibt jedoch einige Situationen, in denen man nicht so argumentieren kann. Wenn etwa innerhalb einer try-catch-Struktur eine Sprunganweisung wie break ausgelöst wird und damit eine umgebende Schleife verlassen werden soll, wird der finally-Block dennoch vorher ausgeführt – außerhalb der gesamten Struktur, jedoch noch innerhalb der Schleife notierter Quelltext allerdings nicht. Also hat diese Klausel immer dann ihre Bedeutung, wenn der Programmfluss umgeleitet wird und bestimmte Schritte vor der Umleitung unumgänglich sind. Ein weiterer Vorteil von finally ist, dass Code, der auf jeden Fall ausgeführt werden soll, nicht gedoppelt werden muss (in try und in catch) und eine Methode, die eine Exception nicht abfangen, sondern weitergeben möchte, trotzdem sicher sein kann, dass wichtiger Abschlusscode (beispielsweise Schließen einer Datei) ausgeführt wird.
204
Exception-Handling
5.5.6
Zwei praktische Beispiele
Die Ausführungen zu Ausnahmen sollen in zwei praktischen Beispielen verdeutlicht werden. Geben Sie bitte Folgendes ein: public class C { public static void main(String args[]) { /* Die Wahl zweier Integerwerte als Divisoren ist nur für ein Beispiel zur Erzeugung einer Ausnahme sinnvoll. Sofern etwa der Zähler als float vereinbart oder ein Casting wie ergebnis = (float)n/m; durchgeführt wird, wird die hier gewünschte Ausnahme bei Division durch 0 nicht mehr ausgelöst. Stattdessen kommt der Wert Infinity zurück. */ int n; int m; float ergebnis=0; try { Integer ueber1 = new Integer(args[0]); Integer ueber2 = new Integer(args[1]); n = ueber1.intValue(); m = ueber2.intValue(); try{ ergebnis = n/m; System.out.println( "Das abgerundete Ergebnis der Division von " + n + " geteilt durch " + m + ": " + ergebnis); } /* catch-Teil der try-catch-finally-Konstruktion */ catch (ArithmeticException ex) { System.out.println("Mathematischer Fehler! " + ex.getMessage()); } } catch (ArrayIndexOutOfBoundsException ex) { // Keine zwei Übergabeparameter. System.out.println( "Das Programm benoetigt 2 Parameter."); }
Objektorientierte Programmierung in Java
205
5 – Erweiterte OO-Techniken
//
catch (NumberFormatException ex) { Keine numerischen Übergabeparameter System.out.println( "Das Programm benoetigt zwei numerische " + "Uebergabeparameter. " + ex.getMessage()); } finally { System.out.println( "Die Division wurde versucht."); } System.out.println("Das Programm ist zu Ende");
} }
In dem Beispiel wird eine try-Struktur verschachtelt. Das Programm erfordert zwei Übergabeparameter, die jeweils einem Integer-Objekt zugewiesen und über die Methode intValue() dann als int-Werte verwendet werden. Wenn Sie als Teiler (der zweite Übergabeparameter) eine 0 übergeben, wird eine ArithmeticException ausgeworfen und im catch-Teil aufgefangen. Das wird in der inneren try-catch-Struktur behandelt. Die anderen Ausnahmen werden in der äußeren try-catch-Struktur behandelt.
Abb. 5.12: Division durch 0 – eine ArithmeticException wird ausgeworfen und aufgefangen
Fehlt ein Übergabewert, wird eine ArrayIndexOutOfBoundsException ausgeworfen und im entsprechenden catch-Teil behandelt.
206
Exception-Handling
Abb. 5.13: Programmaufruf ohne genügend Aufrufparameter
Wird keine Ganzzahl als Übergabewert angegeben, wird eine NumberFormatException ausgeworfen und qualifiziert behandelt.
Abb. 5.14: Der zweite Übergabewert ist nicht numerisch
Ansonsten wird das Ergebnis der Division ausgegeben.
Abb. 5.15: Die Division wurde erfolgreich durchgeführt
Dabei sollte bei allen Varianten zur Kenntnis genommen werden, dass sowohl der Teil, der mit dem Schlüsselwort finally eingeleitet wird, als auch der danach noch folgende Teil des Programms auf jeden Fall ausgeführt werden. Das zeigt deutlich, dass eine auftretende und dann behandelte Ausnahme ein Pro-
Objektorientierte Programmierung in Java
207
5 – Erweiterte OO-Techniken
gramm nicht beendet (es sei denn, Sie programmieren es explizit) oder abstürzen lässt. Beachten Sie, dass wir bewusst zwei Integerwerte durcheinander teilen (auch kein Casting auf einen Nachkommatyp). Sofern Sie etwa den Zähler als float vereinbaren oder so etwas wie ergebnis = (float)n/m; durchführen, werden Sie die für unser Beispiel gewünschte Ausnahme bei der Division durch 0 nicht mehr auslösen. Stattdessen kommt der Wert Infinity zurück. Das zweite Beispiel zeigt eine benutzerdefinierte Ausnahme in Verbindung mit der throw-Anweisung. Das Programm fängt über eine selbst definierte Ausnahme den Start eines Programms ab. Diese ist vom Typ der oben definierten Klasse A. Das Programm darf nur gestartet werden, wenn als Übergabewert ein korrektes Passwort angegeben wird. Andernfalls wird eine Ausnahme ausgeworfen und die im try-Block nachfolgenden Anweisungen werden nicht ausgeführt. Außerdem wird eine Standardausnahme abgefangen (kein Übergabewert). public class D { static void pruefePW(String pw) throws A { A m = new A(); if (!pw.equals("geheim")) { System.out.println( "Start des Programms nur mit Passwort"); throw m; } } public static void main(String args[]){ try { pruefePW(args[0]); System.out.println( "Willkommen im geheimen Bereich"); } catch (A ex) { System.out.println("Nix is." ); } catch(ArrayIndexOutOfBoundsException ex){ System.out.println("Bitte einen Uebergabewert " + "an das Programm eingeben"); } } }
208
Exception-Handling
Abb. 5.16: Das Passwort beim Aufruf war nicht korrekt
Testen Sie nun einmal, was passiert, wenn Sie die Methode ohne umgebendes try-Konstrukt mit passendem catch-Auffangblock verwenden wollen. Kommentieren Sie einfach das try-Schlüsselwort und den gesamten catch-Anteil aus. Der Compiler wird das Programm nicht übersetzen. Grund ist, dass die Methode pruefePW() in der Deklaration eine Ausnahme der Klasse A per throws auflistet. Das bedeutet nicht mehr und nicht weniger, als dass diese bei der Verwendung der Methode unbedingt aufgefangen oder an eine höhere Ebene (hier nicht vorhanden) weitergereicht werden muss. Es genügt aber auch nicht, die Methode in einen try-Block zu packen und zu hoffen, die Ausnahme würde bis zur Systemebene durchgereicht. Konsequent wäre folgende Meldung: 'try' without 'catch' or 'finally' try { ^ 1 error
Wir arbeiten hier mit einer selbst definierten Ausnahme als Ableitung von Throwable. Ein catch-Block der Form catch(Exception e){ }
funktioniert deshalb auch nicht, denn unsere selbst definierte Ausnahme ist ja direkt von Throwable abgeleitet. Es müsste schon so etwas sein: catch(Throwable e){ }
Objektorientierte Programmierung in Java
209
5 – Erweiterte OO-Techniken
Sinnvoll ist dieses maximal allgemeine Abfangen aber selten, denn man möchte ja qualifiziert auf verschiedene Ausnahme reagieren.
5.6 Generische Klassen Eine generische Klasse (oder kurz Generics) ist eine Erweiterung von Java, die ab Java 5 bzw. dem JDK 1.5 in das Konzept integriert wurde. Ein Generic repräsentiert eine ganze Familie von Klassen durch eine parametrisierte Klassenspezifikation. Eine generische Klasse besitzt eine Klasse mit Object-Variablen als Platzhalter. Der Compiler sorgt dafür, dass für die parametrisierten Instanzen der Generics-Klasse automatisch Typumwandlungen von Object in die als Platzhalter angegebenen Typen (und umgekehrt) eingefügt werden. Eine generische Klasse definiert in spitzen Klammern hinter dem Klassennamen einen oder mehrere Parameter, die in der Implementierung einer Klassenschablone wie gewöhnliche Klassenelemente verwendet werden können. Beispiel: public class A
Erst durch Einsetzen der Parameter entsteht eine verwendbare und vollständige Klasse. Die Technologie der Generics erlaubt es, Klassen mit Informationen darüber zu versehen, welche Datentypen sie beim Instanzieren zulassen. Dazu wird dem Konstruktor der übergebene Parameter weitergereicht. Beispiel: A(T1 a, T2 b) { }
Generische Klassen und Vererbung sind zwei zueinander komplementäre Mechanismen für die Erweiterbarkeit und Wiederverwendbarkeit von objektorientierten Softwaresystemen. Vererbung spezialisiert/generalisiert eine Klasse, eine generische Klasse variiert Typen in Klassenspezifikationen. Schauen wir uns ein vollständiges Beispiel an. A sei ein Generic: public class A { private T1 a; private T2 b;
210
Zusammenfassung A(T1 a, T2 b){ this.a = a; this.b = b; } public T1 getA() { return a; } public T2 getB() { return b; } }
In der Klasse B soll A angewendet werden: public class B { public static void main(String[] args) { A