Im Folgenden werden einige Konzepte vorgestellt, die einen entscheidenden Einfluss auf die Programmierung nehmen und komplexere Programmstrukturen ermöglichen.
Schnittstellen, auch als Interfaces bezeichnet, werden dazu verwendet, eine Trennung zwischen Spezifikation und Implementierung herzustellen.
Mit Schnittstellen kann bereits im Vorhinein festgelegt werden, welche Funktionalität von den implementierenden Klassen zur Verfügung gestellt werden soll. Bei der Konzeption einer Schnittstelle muss die Funktionsweise der verwendeten Methoden noch nicht bekannt sein. Schnittstellenklassen beinhalten ausschließlich abstrakte Methoden und Konstanten, die implizit öffentlich sind. Die Klassen selbst enthalten keine eigenen Variablen.
Anstatt des Schlüsselworts class
wird eine Schnittstelle mit interface
deklariert.
Klassen, die eine solche Schnittstelle implementieren wollen, müssen das
Schlüsselwort implements
in ihren Header mit aufnehmen, gefolgt vom
Namen der Schnittstelle.
Dies ermöglicht es, Objekte zu konstruieren, die eine gemeinsame
Schnittstelle aufweisen, obwohl die Klassen nicht voneinander erben.
Nützlich ist eine Schnittstellen-Klasse immer dann, wenn Eigenschaften
einer Klasse überschrieben werden sollen, die nicht direkt in ihrer
normalen Vererbungshierarchie abgebildet werden können oder sollen.
Soll zum Beispiel eine Verwaltung von Distanzsensoren entwickelt
werden, so fällt schnell auf, dass alle eine gibDistanz
-Methode
benötigen. Je nach Sensor können diese Methoden unterschiedlich
implementiert sein, was der Benutzer der Schnittstelle aber nicht zu
wissen braucht.
Somit können die Module, die die gleiche Schnittstelle implementieren, ausgetauscht werden. Das Konzept der Schnittstelle dient also auch der Modularisierung. Das folgende Beispiel zeigt die Implementierung der oben genannten Schnittstelle und der Klassen, die diese Schnittstelle implementieren. In der benutzenden Klasse (FahreBisWand) könnte der SonarSensor durch LaserScanner ersetzt werden, ohne dass sich in der weiteren Funktionsweise etwas ändern würde. Den Benutzer der Schnittstelle brauchen die unterschiedlichen Implementierungen in den jeweiligen Klassen nicht zu interessieren.
Unter einer Ausnahme (engl. exception) versteht man in der Objektorientierten Programmierung das Auftreten bestimmter fehlerhafter Programmzustände. Bei ihrer Behandlung werden Informationen über diese Zustände strukturiert zur Laufzeit weitergegeben und gegebenenfalls an bestimmten Stellen im Programm darauf reagiert. Kann in einem Programm z.B. eine Speicheranforderung nicht erfüllt werden, wird eine Speicheranforderungsausnahme ausgelöst. Mit Hilfe von Exceptions können somit Fehler ignoriert, behoben oder angezeigt werden.
Das Grundprinzip des Exception-Modells in Java lässt sich wie folgt beschreiben [Kr\IeC {\"u}ger2014]:
Als Beispiel für Ausnahmen kann man das Öffnen einer Datei heranziehen. Das Öffnen einer Datei kann aus verschiedenen Gründen fehlschlagen (keine Rechte, Datei nicht vorhanden etc.). Deshalb muss der Programmierer explizit angeben, was geschehen soll, wenn eine Datei nicht geöffnet werden kann (siehe Programm 8.3).
Ausgenommen hiervon sind Ausnahmetypen, die jederzeit auftreten können, wie zum Beispiel Indexfehler bei Indizierung eines Arrays. Diese sind meistens die Folge von Fehlern im Programm, sodass es hier nicht sinnvoll ist, die Ausnahme zu behandeln (stattdessen sollten derartige Fehler behoben werden).
Um Funktionen, die eine Ausnahme erzeugen benutzen zu können,
müssen diese durch einen try-catch
-Block umschlossen sein. Im try
-Teil
wird die Funktion, deren Ausführung gewünscht ist, eingetragen. Es wird
also versucht, die Funktion aufzurufen. Schlägt der Aufruf fehl, wird eine
Ausnahme ausgelöst, die dazu führt, dass die Anweisungen im
catch
-Block ausgeführt werden. Zusätzlich gibt es die Möglichkeit noch
einen finally
- Block einzubauen. Unabhängig davon, ob eine
Ausnahme aufgetreten ist oder nicht, werden die Anweisungen hier
immer ausgeführt. Der Block eignet sich dazu, » Aufräumarbeiten«
durchzuführen.
Eine Exception wird beispielsweise auch geworfen, wenn probiert wird einen Sensor zu initialisieren, obwohl er nicht am angegebenen Sensorport angeschlossen ist.
Programm 8.2 führt ohne angeschlossenen Sensor dann zu folgender Ausgabe auf dem Display des EV3:
Um den Anwender des Programms nicht mit dieser sperrigen (und für ihn
vielleicht unaussagekräftigen) Ausgabe zu konfrontieren, kann ein
try-catch
- Block verwendet und damit auf die Exception reagiert
werden:
Dies führt dann zu folgender Ausgabe:
Neben dem Strukturieren von problematischen Blöcken durch einen
try-catch
-Block gibt es eine weitere Möglichkeit auf Exceptions zu
reagieren. Hierzu wird im Methodenkopf die sogenannte throws
-Klausel
eingeführt.
Im Folgenden zu sehen in einer Methode, die eine Zeile aus einer angegebenen Datei als String zurückgeben soll.
Durch throws
gibt die Methode an, dass sie eine bestimmte Exception
nicht selbst behandelt, sondern diese an die aufrufende Methode
weitergibt. Ist es egal, wo die Fehlerbehandlung in einem Hauptprogramm
behandelt wird, so können alle Fehler auch an die Laufzeitumgebung
weitergeleitet werden, die dann das Programm im Fehlerfall abbricht.
Dafür wird die main
-Methode um eine Exception ergänzt.
Im Gegensatz zur try-catch
-Konstruktion, in der ganz konkret eine
Ausnahmebehandlung für wenige Anweisungen implementiert wird,
deutet throws Exception
nur an, dass in dieser Methode generell eine
Ausnahme auftreten könnte. Somit wird der try-catch
-Block
überflüssig.
Im ersten Programm (es handelt sich um genau zu sein um einen
Programmauszug, da auf die Klassenbezeichnung und die main
-Methode
verzichtet wurde) wird der throws
-Befehl zusammen mit der Angabe
einer konkreten Exception verwendet (hier: FileNotFoundExecption
). Es
lassen sich beliebig viele durch Kommata getrennte Exceptions
angeben.
Das zweite Beispielprogramm verdeutlicht die Weitergabe einer
Fehlerbehandlung an die Laufzeitumgebung (Die main
-Methode wurde
um throws Exception
erweitert.)
Threads schaffen in Java die Möglichkeit, Nebenläufigkeit innerhalb eines Programmablaufs zu erzeugen. Nebenläufigkeit bezeichnet die Fähigkeit, mehrere Vorgänge gleichzeitig ausführen zu können (siehe Abbildung 8.4).
Bei einem Programm mit sequentiellem Ablauf werden alle Programmteile nacheinander durchlaufen. Der Ablauf könnte mit einem Stift nachvollzogen werden. Dabei befindet sich die Spitze des Stiftes zunächst im Programmkopf, durchläuft danach Schleifen und Verzweigungen und landet schließlich am Ende des Programms. Nachteil: Soll ein Roboter während der Ausführung für fünf Sekunden vorwärts fahren, muss das Programm für diesen Zeitraum angehalten werden. In dieser Zeit kann es aber keine anderen Aufgaben mehr erledigen. Ein einzelner Pfad reicht also in manchen Fällen nicht aus. Stattdessen müssen mehrere Pfade gleichzeitig durch das Programm führen. So können beispielsweise auf einem Pfad die Motoren bewegt werden, während auf einem weiteren der Notstopp-Knopf ständig geprüft wird. Die einzelnen Pfad werden als »Thread« bezeichnet.
Nehmen wir einen autonomen Sicherheitsroboter, der nachts über ein Werksgelände fährt und Einbrecher mittels verschiedener Sensoren aufspüren soll. Hier müssen Threads verwendet werden, um die verschiedenen Aufgaben gleichzeitig ausführen zu können. In diesem Fall laufen die Abfragen der Sensoren in jeweils einem eigenen Thread für jeden Sensor, damit immer alle aktuellen Sensordaten zur Verfügung stehen und z.B. die Informationen der normalen Kamera nicht verloren gehen, wenn gerade die Infrarotkamera abgefragt wird. Außerdem muss sich der Roboter zeitgleich noch bewegen können, da er ja über das Gelände patrouillieren soll. Mit mehreren Threads ist es also möglich, verschiedene Aufgaben innerhalb eines Programms gleichzeitig zu erledigen.
Dabei wird ein Thread als Verwalter eingesetzt, der andere Threads
starten oder beenden kann. Das Programm 8.5 auf Seite 201 besteht aus
zwei Klassen. Die Klasse Parent
übernimmt die Rolle des Verwalters. In
der main()
-Methode der Klasse Parent
, wird zunächst ein neues Objekt
mit dem Namen child
vom Typ Child
erzeugt. Die nächste Anweisung
startet den Thread, indem es auf dem Objekt child
die Methode start()
aufruft. Die Methode start()
ist in der Klasse Thread
implementiert, von
der die Klasse Child
erbt.
Nach dem Aufruf von start()
wird die Ausführung an die in der Klasse
Child
implementierte Methode run()
übergeben. Von diesem Zeitpunkt
an laufen die beiden Threads (Eltern- und Kindthread) gleichzeitig und
können parallel Aufgaben erledigen.
Typischerweise ist der Elternthread für die Mensch-Maschine-Interaktion auf der einfachsten Ebene (zum Beispiel beim Notstopp) verantwortlich, während andere Threads konkrete Aufgaben im Anwendungsbereich des Systems haben. Dies rührt daher, dass die Eingabe des Menschen höchste Priorität hat.
Im Programm 8.5 wartet der Elternthread nach dem Start des Kindthreads,
bis die ESCPAE-Taste gedrückt wird, woraufhin das Programm beendet
werden soll. In der Zwischenzeit sollen Im Kindthread die Werte eines
Farbsensor zyklisch mit einer Sekunde Abstand ausgelesen und auf dem
Display des EV3 ausgegeben werden. Dazu wird eine while
-Schleife
verwendet, die mittels Thread.currentThread().isInterrupted()
den
Zustand einer internen booleschen-Variable der Child
-Klasse abfragt.
Diese liefert nur dann true
, falls zuvor die Methode child.interrupt()
aufgerufen wurde (was nach Drücken der ESCAPE-Taste eintritt).
Damit ist die Abbruchbedingung der while
-Schleife erfüllt, diese
wird beendet und die run()
-Methode ist komplett durchlaufen.
Das beendet den Kindthread und in diesem Fall auch das gesamte
Programm.
Da die Farbwerte im Abstand von einer Sekunde ausgelesen werden
sollen, muss der Kindthread nach jedem Durchlauf der while()
-Schleife
für eine Sekunde mittels Thread.sleep(1000)
pausiert werden. Wenn
nun aber in genau dieser Zeit die ESCAPE-Taste betätigt und damit
child.interrupt()
aufgerufen wird, muss der Kindthread seine Pause
frühzeitig beenden, was Thread.sleep(1000)
durch Werfen einer
InterruptedException anzeigt. Diese Exception muss wie weiter oben
beschrieben mit Hilfe eines try-catch
-Blocks gefangen und behandelt
werden. Im Beispiel wird die while
-Schleife mittels break
beendet
und nochmals child.interrupt()
aufgerufen, um die interne
interrupted-Variable auf true
zu setzen.
Wenn bisher von parallel ablaufenden Threads die Rede war, handelte es sich genau genommen um eine Scheinparallelität (oder auch virtuelle Parallelität). Aufgrund der Tatsache, dass der EV3 nur einen Prozessor hat, kann zu jedem Zeitpunkt immer nur eine Aufgabe ausgeführt werden. Aufgaben müssen daher grundsätzlich nacheinander ausgeführt werden, um eine quasi-parallele Ausführung von Aufgaben zu ermöglichen. Die Rechenzeit des Prozessors wird auf die ablaufenden Threads aufgeteilt. Für den Nutzer entsteht dadurch der Eindruck, dass alle Aufgaben gleichzeitig erledigt werden. Allerdings ist die Rechenleistung jedes Prozessors begrenzt. Laufen zu viele Threads gleichzeitig ab, kann auch eine quasi-Parallelität nicht mehr gewährleistet werden. Das Abfragen eines Sensors könnte unter Umständen derart verzögert werden, dass eine sinnvolle Auswertung der Sensorsignale nicht mehr möglich ist.
Um alle Threads gleichmäßig abzuarbeiten, gibt es intelligente Algorithmen, die den Threads die Rechenzeit des Prozessors zuteilen und nach einer gewissen Zeit wieder entziehen. Ein Programm, das einen solchen Algorithmus implementiert, wird Scheduler genannt. Zum Beispiel könnte er den verschiedenen Prozessen zyklisch die Rechenzeit des Prozessors zuteilen1. Dieses Verfahren wird »Round Robin« oder »Zeitscheibenverfahren« genannt.
Systemabhängig wird dabei jedem Thread für eine gewisse Zeit der Prozessor zur Verfügung gestellt. Während ein Thread gerade den Prozessor belegt, sind alle anderen angehalten. Wenn ein gestoppter Thread für das Auslesen eines Sensors zuständig ist, werden Veränderungen der Messwerte erst registriert, wenn dieser Thread wieder den Prozessor belegen darf. Treten Veränderungen nur sehr kurz auf, und liegen zufällig genau in dem Zeitrahmen, in dem der Thread unterbrochen ist, so werden diese gar nicht erfasst. In ungünstigen Fällen verursacht dieses Problem also ungenaue oder verzögerte Messergebnisse bei der Nutzung von Sensoren.
Es gibt einen weiteren verwaltenden Algorithmus: Den sogenannten Garbage Collector (englisch für: Müllabfuhr). Dieser Algorithmus ist dafür verantwortlich, nicht mehr benötigten Speicher wieder freizugeben. Dadurch wird sichergestellt, dass Programmteile, die nicht mehr benötigt werden, den Speicher nicht sinnlos belasten und die so freigemachten Ressourcen den noch laufenden Threads zur Verfügung gestellt werden können. Dies ist besonders interessant, wenn mit mehreren Threads gearbeitet wird. Sollte ein Thread nicht richtig beendet werden, so belegt er weiter Systemressourcen, obwohl er nicht mehr benötigt wird. Diese Programmreste werden dann automatisch nach einer gewissen Zeit vom Garbage Collector eingesammelt (damit sie keinen Speicherplatz mehr verbrauchen ). Der Garbage Collector kann aber auch manuell vom Entwickler aufgerufen werden, um zu bestimmten Zeiten für einen bereinigten Speicher zu sorgen.
1 Die Implementierung eines Schedulers (bzw. zeitlich gesteuerten Ablaufs) wird zum Teil von den Java- Klassen java.util.Timer und java.util.TimerTask übernommen.