14 Java – OOP
Was ist OOP?
Die objektorientierte Programmierung ist ein Programmierparadigma, dass auf dem Konzept der Objektorientierung und der Verwendung von Objekten beruht. Die Grundidee dieses Ansatzes besteht darin, dass die Struktur einer Software an den fundamentalen Bausteinen der realen Welt ausgerichtet wird. Diese Bausteine können sich von Software zu Software unterscheiden und je nach Ansprüchen und Anforderungen einfacher oder komplexer umgesetzt werden.
Um dies zu realisieren, werden die einzelnen Programmfragmente in Objekte unterteilt. Diese Objekte können, abhängig von Eigenschaften und Funktionalitäten, eigene Aufgaben erfüllen oder mit weiteren Objekten agieren. In Kapitel 10 – Klassen wurde bereits ein kleiner Einblick in die OOP mit der Klasse Dog
gegeben.
Ziel der OOP
Ziel der objektorientierten Programmierung ist es modulareren, wartbareren und erweiterbaren Code zu schreiben.
Die Verwendung von Objekten ermöglicht es, den Code in kleinere, unabhängige Einheiten zu unterteilen, die jeweils Daten und Verhaltensweisen in sich vereinen. Die OOP-Prinzipien tragen dazu bei, indem sie eine klare Trennung zwischen den verschiedenen Komponenten des Systems ermöglichen.
Prinzipien der OOP
Die grundlegenden Prinzipien der OOP umfassen die folgenden 4 Konzepte.
Vererbung
Durch Vererbung (engl. inheritance) kann eine Klasse die Objektvariablen und -methoden einer anderen Klasse übernehmen, was die Wiederverwendbarkeit von Code ermöglicht. Um eine Klasse von einer anderen Klasse erben zu lassen, wird das Schlüsselwort extends
verwendet.
In Kapitel 10 – Klassen haben wir die Klasse Dog
erstellt. Nun möchten wir jedoch verschiedene Tierarten, wie zum Beispiel Katzen, in einem Spiel darstellen. Sowohl Katzen als auch Hunde haben gemeinsame Eigenschaften, wie einen Namen und ein Alter. Eine Katze hat zusätzlich noch eine Eigenschaft darüber, ob sie ihre Krallen ausgefahren hat.
Um Gemeinsamkeiten zu nutzen, erstellen wir eine übergeordnete Klasse Animal
(auch Elternklasse oder Superklasse genannt), in der die Eigenschaften name
und age
gespeichert werden und verschieben die Getter und Setter ebenfalls dorthin. Zusätzlich erstellen wir eine neue Klasse Cat
mit der Eigenschaft hasExtendedClaws
. In beiden Klassen muss der passende Konstruktor implementiert werden.
In den Kindklassen rufen wir dann den Konstruktor der Elternklasse mit dem Schlüsselwort super
auf, um die Werte an die Oberklasse zu übergeben und dort zu initialisieren, anstatt dies in der Kindklasse zu tun.
Zudem setzen wir die Objektvariablen name
und age
auf protected
. Dadurch können die Kindklassen direkt auf diese Variablen zugreifen, ohne die Getter verwenden zu müssen.
Nun sollen die Klassen Dog
und Cat
mit extends
von dieser Klasse erben.
Katzen haben zusätzlich zur eat
-Methode und anstelle der bark()
-Methode die Fähigkeit zu "miauen". Also wird dafür eine Methode meow()
in der Klasse Cat
programmiert.
Das Keyword super
funktioniert prinzipiell wie this
– siehe Kapitel 10 – Das Keyword this. Allerdings werden damit der Konstruktor, die Objektvariablen und die Objektmethoden der jeweiligen Superklasse aufgerufen.
Innerhalb des Konstruktors muss als erste Anweisung der Konstruktor der Oberklasse aufgerufen werden, sofern Attribute übergeben werden.
Passen wir die main()
-Methode etwas an.
Die Mutter aller Klassen – Object
Wie wir bereits wissen, gibt es in Java eine Klasse, die über allen anderen steht: Object
. Man könnte sie als die "Mutter aller Klassen" bezeichnen. Jede Klasse erbt automatisch von Object
, auch wenn dies nicht explizit angegeben wird. Das bedeutet, dass alle Klassen direkt oder indirekt Unterklassen von Object
sind.
Object
bietet eine gemeinsame Basis für alle Klassen, was die Interoperabilität und Konsistenz zwischen verschiedenen Klassen und APIs erleichtert. Da jede Klasse von Object
erbt, haben alle Objekte einen gemeinsamen Satz von Methoden, was das Arbeiten mit verschiedenen Datentypen in einer einheitlichen Weise ermöglicht.
Wenn wir eine eigene Klasse erstellen, haben wir die Möglichkeit, diese Methoden zu überschreiben, um das Verhalten der Objekte anzupassen.
Grundlegende Methoden der Klasse Object
Im Folgenden sind einige Methoden der Klasse Object
aufgelistet, die überschrieben werden können.
Methode | Beschreibung |
---|---|
| Überprüft die Gleichheit von Objekten. Standardmäßig prüft sie, ob zwei Referenzen auf dasselbe Objekt zeigen. Wir kennen bereits eine Implementierung dieser Methode bei einem String-Objekt. |
| Liefert einen Hash-Code für das Objekt, der häufig in Hash-basierten Collections wie |
| Gibt eine String-Darstellung des Objekts zurück. Standardmäßig gibt sie den Klassennamen gefolgt von der Speicheradresse des Objekts zurück. Auch diese Implementierung kennen wir bereits aus Kapitel 11 – Objekte: Die toString()-Methode. |
| Eine Kopie eines Objekts kann mit dieser Methode erstellt werden. Zu beachten ist jedoch, dass das Klonen in Java spezielle Implementierungen erfordert und nicht für alle Klassen sinnvoll ist. Die Methode selbst ist in der |
| Wird aufgerufen, bevor das Garbage Collection-Subsystem ein Objekt endgültig zerstört. Die Verwendung von |
Das Keyword instanceof
Das Keyword instanceof
wird verwendet, um zu überprüfen, ob ein Objekt ein Exemplar einer bestimmten Klasse oder eines Interfaces ist. Trifft dies zu, kann das entsprechende Objekt in den jeweiligen Typ gecastet werden, um typspezifische Anweisungen durchzuführen.
Pattern Matching bei instanceof
ab Java 16
Bislang musste nach jeder Typüberprüfung ein separater Cast in den entsprechenden Typ erfolgen, der in einer Hilfsvariable gespeichert wurde, um typspezifische Anweisungen ausführen zu können. Seit Java 16 ist dies nun mit einer kürzeren und eleganteren Schreibweise möglich.
Abstraktion
Abstraktion bedeutet, dass komplexe Systeme auf eine einfachere und klarere Weise dargestellt werden können, indem unnötige Details ausgeblendet werden.
Schaut man sich dazu die Klassen Animal
, Dog
und Cat
an, fällt auf, dass dieses Prinzip bereits teilweise umgesetzt wurde. Denn die gemeinsamen Eigenschaften name
und age
wurden in der Oberklasse Animal
zusammengefasst. Genauso wie deren Getter und Setter. Somit wurde vermieden, dass die Objektvariablen und -methoden sowohl in der Klasse Dog
als auch in der Klasse Cat
stehen.
Besitzt eine Katze eine Eigenschaft, die ein Hund nicht besitzt, macht es keinen Sinn diese Eigenschaft in der Oberklasse zu speichern. Es reicht aus, diese Eigenschaft nur für Objekte der Klasse Cat
zu speichern.
Die Klassen können allerdings noch weiter abstrahiert werden. Sowohl Hunde als auch Katzen können fressen. Also kann auch die Methode eat()
in die Oberklasse ausgelagert werden. super
muss in der Methode eat()
allerdings noch zu this
geändert werden, da es keine Eigenschaft name
in der Oberklasse Object
gibt.
Befindet sich eine Variable in der Oberklasse, kann mit der entsprechenden Berechtigung auch mit this
aus der Unterklasse auf diese zugegriffen werden. Es kann natürlich auch ganz weggelassen werden, da es keine gleichnamige lokale Variable innerhalb dieser Objektmethode gibt.
Ich selbst bevorzuge die expliziten Angaben mit this
für Objektvariablen und Objektmethoden in derselben Klasse und super
für den Aufruf von Objektvariablen und Objektmethoden in der Oberklasse. Dadurch wird klarer, in welchen Klassen die Variablen und Methoden deklariert sind.
Kapselung
Kapselung bedeutet, dass der interne Zustand eines Objekts durch eine öffentliche Schnittstelle verborgen wird, um unerwartete Änderungen und Fehler zu vermeiden bzw. das Lesen oder Ändern von Daten einzuschränken.
Auch dieses Konzept wurde bereits in den Klassen Animal
, Dog
, und Cat
angewendet. Dort wurden die Objektvariablen auf private
, später auf protected
gesetzt und Getter und Setter als Kontrollpunkte für den Zugriff auf diese verwendet.
Etwas zu schreiben wie
sollte logischerweise nicht möglich sein.
Dies ist zwar auch durch Setter möglich, doch durch Setter haben wir den Vorteil, dass wir solche Fälle abfragen können.
Polymorphismus
Polymorphismus bietet die Möglichkeit, dass verschiedene Typen so agieren, als wären sie derselbe Typ. Das Verhalten jedes Typs ist jedoch unterschiedlich. Klinkt kompliziert?
Schauen wir uns dazu nochmal die Klassen Animal
, Dog
und Cat
an. Fügen wir zur Klasse Animal
eine Methode giveLoud()
, ändern die Namen der Methoden bark()
und meow()
in den Kindklassen ebenfalls zum Namen giveLoad()
und passen die main()
-Methode etwas an.
Welche Objektmethoden werden aufgerufen? Die aus der Elternklasse oder aus der Kindklasse?
Die Methoden der Kindklassen werden aufgerufen.
Wir speichern zwar eine Referenz auf die Objekte in einer lokalen Variablen vom Typ Animal
, aber die Objekte agieren wie ein Cat
- und Dog
-Objekt.
Was in diesen Klassen nun passiert ist, dass innerhalb der Kindklassen das Verhalten der Klasse Animal
überschrieben wird, indem die Objektmethode giveLoad()
mit einer eigenen Implementierung der Kindklasse überschrieben wird. Es werden immer die Objektmethoden der Kindklassen aufgerufen, sofern diese eine eigene Implementierung mitbringen.
Der Compiler meckert zwar nicht herum, allerdings zeugt es von sauberem Arbeiten, wenn oberhalb der überschriebenen Methoden die Annotation @Override
steht.
Es können auch die Objektmethoden der Oberklassen aus den Objektmethoden der Kindklassen aufgerufen werden. Dies macht aber nur Sinn, wenn innerhalb der Methoden der Oberklasse Code geschrieben wurde, der für die jeweilige Kindklasse ebenfalls relevant ist.
Abstrakte Klassen
Bis jetzt gibt es im obigen Programm nur Hunde und Katzen. Da beide Tiere Laute von sich geben, können die Kindklassen dazu gezwungen werden, die Objektmethode giveLoud()
zu implementieren, indem in der Oberklasse Animal
die Methode giveLoud()
als abstract
deklariert wird.
Diese Methode hat jetzt nur noch eine Signatur, jedoch keine eigene Implementierung. Zusätzlich muss der Klasse Animal
noch das Keyword abstract
verpasst werden, da mindestens eine Methode existiert, die als abstract
deklariert wurde.
Dadurch wird verhindert, dass ein Objekt dieser Klasse erstellt werden kann. Ein neues Objekt muss also immer ein konkretes Tier sein.
Folgendes ist somit nicht mehr erlaubt:
Das macht auch Sinn. In der Klasse Animal
haben wir eine Methode mit abstract
deklariert und somit keine eigene Implementierung mitbringt.
Angenommen wir hätten dennoch die Möglichkeit ein Objekt dieser Klasse zu erzeugen und rufen die abstrakte Methode über dieses Objekt auf, was würde passieren? Man weiß es nicht ... und die Methode auch nicht, denn sie hat ja keine Implementierung. Die Methode besitzt keinen ausführbaren Code.
Die Instanziierung und den möglichen Aufruf dieser Methode, wird durch die Deklarierung der Klasse mit abstract
verhindert.
Werden nun die Objektmethoden aus der Kindklasse entfernt oder werden weitere Klassen erstellt, die von der Klasse Animal
erben, dann gibt es einen Compiler-Fehler der folgenden Art, bis die notwendigen Methoden überschrieben wurden.
Class 'Dog' must either be declared abstract or implement abstract method 'giveLoud()' in 'Animal'
Dieses Vorgehen ist nützlich, wenn Kindklassen ihre eigenen Implementierungen mitbringen sollen.
Interfaces
Eine Alternative zu abstrakten Klassen sind Interfaces (Schnittstellen). Interfaces dienen dazu, bestimmte Verhalten zu definieren, ohne eine konkrete Implementierung vorzugeben. Solche Methodendeklarationen werden häufig auch Signaturen genannt (wie abstrakte Methoden in abstrakten Klassen).
Klassen können diese Interfaces und diese vorgeschriebene Funktionalität dann implementieren, wobei auch die Keywords interface
und implements
Anwendung finden. Interfaces werden meistens wie Top-Level-Klassen in eigenen Dateien geschrieben, können aber auch direkt in einer Klasse definiert werden.
Alle Methoden die innerhalb von Schnittstellen deklariert werden, sind implizit
public
undabstract
, außer Default-Methoden.Alle Variablen die innerhalb von Schnittstellen deklariert werden, sind implizit Konstanten (
static final
).
Im obigen Beispiel mit den Tier-Klassen kommt die Verwendung der Method giveLoud()
zum Einsatz. Wird dies mit Schnittstellen realisiert, sieht das Ganze folgendermaßen aus:
Die Klassen Dog
und Cat
behalten ihren Code bei. In der Klasse Animal
kann die abstrakte Definition der Methode giveLoud()
jedoch entfernt werden, da diese bereits im Interface Loudable
existiert. Das Prinzip bleibt also dasselbe.
Hier zeigt sich die wahre Stärke von Interfaces. Angenommen wir haben noch andere Objekte die Geräusche erzeugen, jedoch keine Tiere sind – etwa ein Motor. Ein Motor kann Geräusche machen, ist aber kein Tier. Da aber sowohl Tiere als auch Motoren Geräusche machen können, kann sowohl die Klasse Animal
als auch eine Klasse Motor
das Interface Loudable
implementieren.
Dieses Interface ermöglicht es den Klassen, die Geräuscherzeugung auf ihre eigene Weise zu implementieren, ohne von einer gemeinsamen Oberklasse erben zu müssen.
Um einen passenderen Namen für das Interface zu nehmen, könnte man es anstelle von Loudable
in Soundable
und die Methode giveLoud()
in makeSound()
umbenennen.
Ohne den Kontext zu kennen, ist es schwer zu verstehen, was dieses Interface und seine Methode tun sollen. Umso mehr, wenn es in zwei solch unterschiedlichen Umgebungen verwendet wird. Loudable
ist kein geläufiger Begriff und giveLoud()
klingt eher nach einer Anweisung wie "Ton geben", was nicht intuitiv ist.
Im Gegensatz dazu deutet ein Interface namens Soundable
mit einer Methode makeSound()
sofort darauf hin, dass es sich um etwas handelt, das Geräusche erzeugen kann. Der Name ist selbsterklärend und macht den Code verständlicher.
Wichtige Schnittstellen in der Java-Standardbibliothek
Interface | Beschreibung |
---|---|
| Etwas hinzufügen |
| Ressourcen automatisch schließen |
| Objekt vergleichen |
| Elemente in Schleifen durchlaufen |
| Etwas lesen |
| Code nebenläufig ausführen |
| Objekt serialisieren |
| Dateien filtern |
| Aufzählungen verwalten |
| Listen verwalten |
| Maps verwalten |
| Sets verwalten |
| Auf Events reagieren |
Funktionale Schnittstellen
Schnittstellen, die nur eine Methode definieren, werden als funktionale Schnittstellen bezeichnet und können mittels Lambda-Ausdrücken verwendet werden. Wenn Schnittstellen nur eine Methode definieren sollen, kann diese Schnittstelle mit der Annotation @FunctionalInterface
versehen werden, sofern keine Default-Implementierung verwendet wird. Dadurch überprüft der Compiler, ob die Regeln für funktionale Schnittstellen eingehalten werden.
Dies ermöglicht eine vereinfachte Schreibweise beim Aufruf dieser Methoden. Bevor jedoch die Annotation verwendet wird, sollte man sorgfältig abwägen, ob das Interface später möglicherweise um weitere Methoden erweitert wird.
Falls ja und die Annotation wurde bereits hinzugefügt, könnte dies zu Inkompatibilitäten führen und vorhandenen Code, der das Interface nutzt, beeinträchtigen.
Default-Methoden
Interfaces können auch mit einer Default-Implementierung versehen werden. Dabei wir das Keyword default
verwendet.
Dadurch werden die Klassen, die dieses Interface implementieren, nicht mehr dazu gezwungen, Methoden zu überschreiben, können dies aber bei Bedarf dennoch tun.
Abstrakte Klassen vs. Schnittstellen
Die prinzipielle Anwendung zwischen Schnittstellen und abstrakten Klassen bleibt dieselbe und beide besitzen viele Ähnlichkeiten und können auch auf ähnliche Weise eingesetzt werden. Zudem können Interfaces auch von anderen Interfaces erben. Allerdings haben Schnittstellen einen fundamentalen Unterschied.
Während Klassen nur von einer einzigen Klasse erben können (ob direkt oder indirekt), können mehrere Interfaces implementiert werden. Dies ist zwar auch durch mehrstufige Vererbung möglich, allerdings können dadurch tiefe und unübersichtliche Hierarchien entstehen.
Versiegelte Klassen und Interfaces
Versiegelte Klassen und Interfaces, eingeführt als Preview in Java 15 und finalisiert mit Java 17, bieten eine neue Möglichkeit, die Vererbungshierarchie zu kontrollieren. Sie ermöglichen es Entwicklern klar und präzise festzulegen, welche Klassen oder Interfaces die Möglichkeit haben, eine bestimmte Klasse zu erweitern oder ein Interface zu implementieren.
Eine versiegelte Klasse ist eine Klasse, die explizit festlegt, welche anderen Klassen sie erweitern dürfen. Ebenso können versiegelte Interfaces bestimmen, welche anderen Interfaces oder Klassen sie implementieren dürfen. Dies erfolgt durch die Verwendung des neuen Keywords sealed
in der Klassendeklaration, gefolgt von dem Schlüsselwort permits
und einer Liste der zulässigen Subklassen.
Versiegelte Klassen und Interfaces bieten auch Flexibilität, da eine versiegelte Klasse nicht zwingend final
sein muss. Stattdessen können die erlaubten Subklassen selbst wieder versiegelt sein, was eine kontrollierte Vererbungskette ermöglicht.
Alle Unterklassen die von einer versiegelten Klasse erben, müssen entweder als sealed
, non-sealed
oder final
deklariert werden.
Wenn eine (Unter-)Klasse als
final
deklariert wird, kann sie nicht weiter vererbt werden. Das bedeutet, dass diese Klasse die Endstation in der Vererbungshierarchie ist.Wenn eine Unterklasse wiederum als
sealed
deklariert wird, setzt sie die versiegelte Vererbungshierarchie fort. Das bedeutet, dass diese Klasse nur von einer definierten Gruppe von Klassen erweitert werden kann, die explizit in derpermits
-Liste angegeben sind. Dies ermöglicht eine weitere Vererbung, jedoch unter strenger Kontrolle, welche Klassen beteiligt sind.Wenn eine Unterklasse als
non-sealed
deklariert wird, wird die Vererbungshierarchie geöffnet und die Klasse kann wie eine normale Klasse weiter vererbt werden, ohne Beschränkungen.
Im folgenden Beispiel legt das versiegelte Interface Role
fest, dass nur die Klassen Admin
, Moderator
und Guest
eine Rolle im Programm übernehmen können. Die Klasse Guest
ist als non-sealed
deklariert, sodass sie von weiteren Klassen erweitert werden kann, falls zusätzliche spezifische Gastrollen erforderlich sind.