Tech Archive Help

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.

public class Animal { protected String name; protected int age; public Animal(String name, int age) { this.name = name; this.age = age; } public Animal(String name) { this(name, 0); } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } }

Nun sollen die Klassen Dog und Cat mit extends von dieser Klasse erben.

public class Dog extends Animal { public Dog(String name, int age) { super(name, age); } public Dog(String name) { super(name); } public void eat() { System.out.println(super.name + " is eating!"); } public void bark() { System.out.println(super.name + " barks!"); } }

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.

public class Cat extends Animal { private boolean hasExtendedClaws; public Cat(String name, int age) { super(name, age); } public Cat(String name) { super(name); } public void eat() { System.out.println(super.name + " is eating!"); } public void meow() { System.out.println(super.name + " meows!"); } public boolean isHasExtendedClaws() { return hasExtendedClaws; } public void setHasExtendedClaws(boolean hasExtendedClaws) { this.hasExtendedClaws = hasExtendedClaws; } }

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.

public class Main { public static void main(String[] args) { Dog killer = new Dog("Killer", 5); Cat nala = new Cat("Nala", 3); killer.eat(); // Killer is eating! killer.bark(); // Killer barks! nala.eat(); // Nala is eating! nala.meow(); // Nala meows! System.out.println(killer.getName()); // Killer System.out.println(killer.getAge()); // 5 System.out.println(nala.getName()); // Nala System.out.println(nala.getAge()); // 3 } }

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

equals()

Ü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.

hashCode

Liefert einen Hash-Code für das Objekt, der häufig in Hash-basierten Collections wie HashMap oder HashSet verwendet wird. Wenn equals() überschrieben wird, sollte auch hashCode() überschrieben werden, um sicherzustellen, dass Objekte, die als gleich betrachtet werden, denselben Hash-Code haben.

toString()

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.

clone()

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 Object-Klasse als native deklariert – siehe JNI und Kapitel 12 – Modifizierer und Zugriffsrechte

finalize()

Wird aufgerufen, bevor das Garbage Collection-Subsystem ein Objekt endgültig zerstört. Die Verwendung von finalize() ist jedoch in modernen Java-Anwendungen nicht mehr üblich. Die Methode selbst ist mit @Deprecated markiert und wird zukünftig entfernt werden.

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.

public class Animal { protected String name; protected int age; public Animal(String name, int age) { this.name = name; this.age = age; } public Animal(String name) { this(name, 0); } public void eat() { System.out.println(this.name + " is eating!"); } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } }
public class Dog extends Animal { public Dog(String name, int age) { super(name, age); } public Dog(String name) { super(name); } public void bark() { System.out.println(super.name + " barks!"); } }
public class Cat extends Animal { private boolean hasExtendedClaws; public Cat(String name, int age) { super(name, age); } public Cat(String name) { super(name); } public void meow() { System.out.println(super.name + " meows!"); } public boolean isHasExtendedClaws() { return hasExtendedClaws; } public void setHasExtendedClaws(boolean hasExtendedClaws) { this.hasExtendedClaws = hasExtendedClaws; } }

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 gesetzt, später auf protected gesetzt und Getter und Setter als Kontrollpunkte für den Zugriff auf diese verwendet.

Etwas zu schreiben wie

nala.age = -10;

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.

public void setAge(int age) { if (age < 0) return; this.age = age; }

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.

public class Animal { protected String name; protected int age; public Animal(String name, int age) { this.name = name; this.age = age; } public Animal(String name) { this(name, 0); } public void giveLoud() { System.out.println(this.name + " makes a sound!"); } public void eat() { System.out.println(this.name + " is eating!"); } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } }
public class Dog extends Animal { public Dog(String name, int age) { super(name, age); } public Dog(String name) { super(name); } public void giveLoud() { System.out.println(super.name + " bark!"); } }
public class Cat extends Animal { private boolean hasExtendedClaws; public Cat(String name, int age) { super(name, age); } public Cat(String name) { super(name); } public void giveLoud() { System.out.println(super.name + " meows!"); } public boolean isHasExtendedClaws() { return hasExtendedClaws; } public void setHasExtendedClaws(boolean hasExtendedClaws) { this.hasExtendedClaws = hasExtendedClaws; } }
public class Main { public static void main(String[] args) { Animal killer = new Dog("Killer", 5); Animal nala = new Cat("Nala", 3); killer.eat(); killer.giveLoud(); nala.eat(); nala.giveLoud(); System.out.println(killer.getName()); // Killer System.out.println(killer.getAge()); // 5 System.out.println(nala.getName()); // Nala System.out.println(nala.getAge()); // 3 } }

Welche Objektmethoden werden aufgerufen? Die aus der Elternklasse oder aus der Kindklasse?


Die Methoden der Kindklassen werden aufgerufen.

Bello is eating! Bello bark! Nala is eating! Nala meows!

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.

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.

@Override public void giveLoud() { super.giveLoud(); System.out.println(super.name + " bark!"); }

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.

public abstract void giveLoud();

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.

public abstract class Animal { // ... }

Dadurch wird verhindert, dass ein Objekt dieser Klasse erstellt werden kann. Ein neues Objekt muss also immer ein konkretes Tier sein.

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 oder einer ähnlichen Art, bis die notwendigen Methoden überschrieben wurden.

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 Fähigkeiten oder 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 und abstract, 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:

public interface Loudable { void giveLoud(); }
public abstract class Animal implements Loudable { // ... public abstract void giveLoud(); // Can be removed // ... }

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 leichter lesbar und wartbar.

Wichtige Schnittstellen in der Java-Standardbibliothek

Interface

Beschreibung

java.lang.Appendable

Etwas hinzufügen

java.lang.AutoCloseable

Ressourcen automatisch schließen

java.lang.Comparable

Objekt vergleichen

java.lang.Iterable

Elemente in Schleifen durchlaufen

java.lang.Readable

Etwas lesen

java.lang.Runnable

Code nebenläufig ausführen

java.io.Serializeable

Objekt serialisieren

java.nio.file.PathMatcher

Dateien filtern

java.util.Collection

Aufzählungen verwalten

java.util.List

Listen verwalten

java.util.Map

Maps verwalten

java.util.Set

Sets verwalten

java.awt.event.ActionListener

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.

@FunctionalInterface public interface Soundable { void makeSound(); }

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.

public interface Soundable { default void makeSound(String name) { System.out.println(name + " makes a sound!"); } }

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 (von Object mal abgesehen) erben können, 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.

public sealed class Animal permits Dog, Cat { // ... } public final class Dog extends Animal { // ... } public final class Cat extends Animal { // ... }

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.

public sealed class Animal permits Dog, Cat { // ... } public final class Dog extends Animal { // ... } public sealed class Cat extends Animal permits PersianCat, SiameseCat { // ... } public final class PersianCat extends Cat { // ... } public non-sealed class SiameseCat extends Cat { // ... }
  • 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 der permits-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.

public sealed interface Role permits Admin, Moderator, Guest { void access(); } public final class Admin implements Role { @Override public void access() { System.out.println("Admin has full access"); } } public final class Moderator implements Role { @Override public void access() { System.out.println("Moderator has limited access"); } } public non-sealed class Guest implements Role { @Override public void access() { System.out.println("Guest has minimal access"); } }
Last modified: 19 August 2024