Objektorientiertes Design

Modul #J3

Ziele

  • Ich kann die Konzepte der Generalisierung und Spezialisierung im Kontext der Vererbung erklären und anwenden.
  • Ich weiss, wie ich Unterklassen von einer Oberklasse ableiten kann (extends).
  • Ich kann vererbte Methoden und Attribute einer Klasse von lokalen Methoden und Attributen unterscheiden.
  • Ich kann in UML (Unified Modeling Language) die Vererbungsbeziehung zweier Klassen erkennen.
  • Ich weiss, wie ich das Schlüsselwort super nutze, um den Konstruktor der Oberklasse zu verwenden.
  • Ich weiss, wie ich das Schlüsselwort super nutze, um eine Methode aus der Oberklasse aufzurufen.
  • Ich kann das Konzept des “Überschreibens” (Overriding) von Methoden und Attributen erklären, erkennen und nutzen.
  • Ich weiss, was Interfaces sind und wann es angebracht ist, ein Interface zu verwenden.
  • Ich weiss, welche Methoden und Felder in einem Interface vorhanden sind und wie sie geschrieben werden bzw. über welche Eigenschaften sie verfügen.
  • Ich kann eine Klasse schreiben, die ein Interface implementiert.

Einführung

Beim objektorientierten Design (OOD) handelt es sich um die Modellierung der realen Welt in Klassen und Objekten. In dieser Phase der Softwareentwicklung werden Objekte und Klassen definiert sowie ihre Eigenschaften, Funktionen und die Beziehungen untereinander festgelegt.

Die wichtigsten Konzepte/Prinzipien des objektorientierten Designs sind:

  • Vererbung ermöglicht es in Java, eine hierarchische Ordnung für Klassen festzulegen. Dadurch wird die Menge an redundantem Code reduziert.
  • Polymorphismus beschreibt eine Sprachstruktur, bei der Methoden mit derselben Signatur unterschiedlich implementiert werden können und dadurch verschiedene Ergebnisse liefern.
  • Abstraktion ist ein Prinzip, bei dem durch das Weglassen von Details nur die wesentlichen Eigenschaften eines Objekts hervorgehoben werden.
  • Kapselung ermöglicht es, den Zugriff auf die Methoden und Attribute einer Klasse zu kontrollieren und zu schützen.

Das Befolgen dieser Konzepte/Prinzipien führt zu einem guten objektorientierten Design und trägt wesentlich zur Qualität der Software bei.

Vererbung

Vererbung ist ein Mechanismus zum Ableiten einer neuen Klasse von einer anderen Klasse. Die neue Klasse erbt alle nicht-privaten Felder und Methoden der Basisklasse. Die Vererbung ist eines der Hauptkonzepte der objektorientierten Programmierung.

Als Beispiel dient uns die Klasse Auto, welche von der Klasse Fahrzeug abgeleitet ist. Die Beziehung zwischen den Klassen wird als IS-A Beziehung bezeichnet. Wir würden also logischerweise sagen, dass ein Auto ein Fahrzeug ist. Die Klasse Auto wird auch als Spezialisierung der Klasse Fahrzeug bezeichnet. Umgekehrt bezeichnen wir die Klasse Fahrzeug als Generalisierung der Klasse Auto.

Synonyme für die abgeleitete Klasse sind:

  • Unterklasse (Subclass),
  • abgeleitete Klasse (Derived Class),
  • erweiterte Klasse (Extended Class),
  • Kind-Klasse (Child Class).

Synonyme für die Klasse, von der abgeleitet wird:

  • Oberklasse (Superclass),
  • Basisklasse (Base Class),
  • Eltern-Klasse (Parent Class).

Der Anwendungsfall der Vererbung kommt also dort zum Tragen, wo es eine IS-A Beziehung zwischen zwei Objekten gibt. Dazu ein paar Beispiele:

  • Ein Quadrat ist eine geometrische Form.
  • Java ist eine Programmiersprache.
  • Ein Schwert ist eine Klingenwaffe.
  • Eine Klingenwaffe ist eine Waffe.
SuperklasseSubklasse
Geometrische FormQuadrat
ProgrammierspracheJava
KlingenwaffeSchwert
WaffeKlingenwaffe

Es gibt einige wichtige Punkte zur Vererbung in Java:

  • In Java gibt es keine Mehrfachvererbung. Eine Klasse kann immer nur von maximal einer anderen Klasse erben
  • Eine Klassenhierarchie kann beliebig viele Ebenen haben
    • Die Klasse Schwert erbt von der Klasse Klingenwaffe und die Klasse Klingenwaffe erbt von der Klasse Waffe
  • Eine Superklasse kann beliebig viele Subklassen haben

Im UML-Diagramm sind die Basisklassen oberhalb der abgeleiteten Klassen abgebildet. Die Klassen werden mit Pfeilen verbunden, wobei die Pfeilrichtung von der abgeleiteten Klasse in Richtung der Basisklasse verläuft. Der Vererbungspfeil hat eine durchgezogene Linie und ein geschlossenes Dreieck als Pfeilspitze.

Eine Subklasse kann beliebig viele neue Felder und Methoden enthalten. Geerbte und neu hinzugefügte Felder und Methoden werden wie bisher gelernt verwendet.

Das Schlüsselwort extends

In Java wird eine Vererbungsbeziehung implementiert, indem wir das Schlüsselwort extends verwenden.

1
2
3
public class Fahrzeug {

}
1
2
3
public class Auto extends Fahrzeug {

}

Bei der Deklaration eines Autos ist es nun aufgrund der Vererbungsbeziehung möglich, dass wir statt eines Autos ein Fahrzeug verwenden. Dies funktioniert, weil ein Auto ja ein Fahrzeug ist (IS-A).

1
2
3
4
5
6
public class Main {

    public static void main(String[] args) {
        Fahrzeug fahrzeug = new Auto();
    }
}

Das Schlüsselwort final

Wenn eine Klasse mit dem Schlüsselwort final versehen wird, dann kann sie keine Subklassen haben. Wir können die Vererbung also verbieten.

1
2
3
public final class NonDerivableClass {

}

Viele der Standardklassen von Java sind final. Dazu gehören alle Wrapper-Klassen von primitiven Datentypen wie Integer, Long oder Float und die Klasse String.

Das Schlüsselwort super

Das Schlüsselwort super ähnelt dem Schlüsselwort this. Es erlaubt den direkten Zugriff auf Felder, Konstruktoren und Methoden der Superklasse. Bei gleicher Namensgebung von Feldern oder beim Überschreiben von Methoden ist es teilweise sogar zwingend notwendig.

Umgang mit Konstruktoren

Konstruktoren werden nicht an die Subklasse vererbt. Wenn aber ein neues Objekt einer Subklasse erzeugt werden soll, so kann der Konstruktor der Superklasse nicht einfach ignoriert werden. Beim Erzeugen von Objekten einer Subklasse unterscheiden wir zwischen den folgenden Fällen:

  • Die Superklasse hat keinen Konstruktor (das heisst, sie besitzt einen Default-Konstruktor).
  • Die Superklasse hat einen anderen Konstruktor als den Default-Konstruktor.
  • Die Superklasse hat einen anderen Konstruktor und zusätzlich einen Default-Konstruktor.

Beispiel 1 - Die Superklasse hat keinen Konstruktor

1
2
3
public class Fahrzeug {

}
1
2
3
public class Auto extends Fahrzeug {

}
1
2
3
4
5
6
public class Main {

    public static void main(String[] args) {
        Fahrzeug auto = new Auto();
    }
}

Beispiel 2 - Die Superklasse hat einen anderen Konstruktor als den Default-Konstruktor

1
2
3
4
5
6
7
8
public class Fahrzeug {

    private String marke;

    public Fahrzeug(String marke) {
        this.marke = marke;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class Auto extends Fahrzeug {

    // Möglichkeit 1 - Konstruktor-Weiterleitung
    public Auto(String marke) {
        super(marke);
    }

    // Möglichkeit 2 - Fixer Wert
    public Auto() {
        super("Unbekannt");
    }
}
1
2
3
4
5
6
7
8
9
public class Main {

    public static void main(String[] args) {
        // Möglichkeit 1
        Fahrzeug ferrari = new Auto("Ferrari");
        // Möglichkeit 2
        Fahrzeug any = new Auto();
    }
}

Für die Erzegung eines Fahrzeugs ist nun eine Marke notwendig. Dies bedeutet automatisch, dass die Erzeugung eines Autos auch einen Wert für diese Marke besitzen muss. Der Wert kann entweder über einen weiteren Konstruktor in der Klasse Auto in das Fahrzeug gelangen oder man wählt - wie im Beispiel gezeigt - einen fixen Wert. Die Erzeugung eines neuen Autos ohne einen Wert für die Marke ist aber nicht möglich, da die Superklasse einen Wert verlangt.

Beispiel 3 - Die Superklasse hat einen anderen Konstruktor und zusätzlich einen Default-Konstruktor

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class Fahrzeug {

    private String marke;

    public Fahrzeug() {
        this.marke = "Unbekannt";
    }

    public Fahrzeug(String marke) {
        this.marke = marke;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class Auto extends Fahrzeug {

    public Auto() {
        super();
    }

    public Auto(String marke) {
        super(marke);
    }
}
1
2
3
4
5
6
7
public class Main {

    public static void main(String[] args) {
        Fahrzeug ferrari = new Auto("Ferrari");
        Fahrzeug any = new Auto();
    }
}

Hier gelten die gleichen Regeln wie beim Beispiel 2. Der einzige Unterschied besteht nun darin, dass die Klasse Auto ebenfalls beide Konstruktoren besitzen muss.

Der geübte Entwickler behält also stets die Konstruktoren der Superklasse im Auge. Sie werden immer vor den Konstruktoren der Subklasse aufgerufen. Dies ist auch der Grund, weshalb der Aufruf des Super-Konstruktors immer als erstes Statement in einem Subklassen-Konstruktor aufgeführt werden muss.

Beziehungen

In Java gibt es vier Grundtypen von Beziehungen, welche Objekte miteinander bilden können. Diese sind:

  • Generalisierung und Spezialisierung (IS-A Beziehung)
  • Aggregation und Komposition (HAS-A Beziehung)
  • Assoziationen (KNOWS-A Beziehung)
  • Abhängigkeit (USES Beziehung)

Generalisierung und Spezialisierung (IS-A Beziehung)

Die IS-A Beziehung beschreibt, wovon sich ein Objekt ableitet. Dies gilt für Basisklassen, abstrakte Klassen und Interfaces. Je genereller eine Funktion oder Beschreibung ist, desto höher ist sie stets in der Klassenhierarchie. Weiter unten in der Hierarchie sind also die spezialisierten Dinge anzutreffen.

Darstellung der Generalisierung mit UML:

1
2
3
4
5
6
7
public class Fahrzeug {
    //...
}

public class Auto extends Fahrzeug {
    //...
}

Bei der Implementation eines Interfaces mit UML wird die folgende Darstellung verwendet (Interfaces werden weiter unten erklärt):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// Interface Flyable
interface Flyable {

    void fly(); // Methode zum Fliegen, die implementiert werden muss
}

// Oberklasse Bird
class Bird {
    // Leere Klasse Bird, kann später Attribute und Methoden haben
}

// Unterklasse Crow, die von Bird erbt und das Flyable-Interface implementiert
class Crow extends Bird implements Flyable {

    // Implementierung der Methode aus dem Flyable-Interface
    @Override
    public void fly() {
        System.out.println("Die Krähe fliegt!");
    }
}

Aggregation und Komposition (HAS-A Beziehung)

Die HAS-A Beziehung beschreibt, woraus sich ein Objekt zusammensetzt. Ein Objekt kann selbstverständlich beliebig viele andere Objekte aufnehmen. Die aufgenommenen Objekte sind dabei Bestandteile des Hauptobjekts.

Ein einfaches Beispiel wäre, dass ein Auto (normalerweise) einen Motor hat.

1
2
3
public class Motor {

}
1
2
3
4
public class Auto {

    private Motor motor;
}

Die Umsetzung beider Beziehungen wird durch Instanzvariablen abgebildet, welche die entsprechenden Objekte aufnehmen. Ist bei einer Aggregation das verbundene Objekt nicht vorhanden, so wird der Instanzvariable der Wert null zugewiesen. Wenn die Beziehung zwischen den Objekten mehrfach (1 zu n) vorhanden ist, so kann dafür ein Array oder auch eine Liste verwendet werden.

Bei dieser Beziehung wird zwischen Aggregation und Komposition unterschieden.

Aggregation Die Aggregation ist

  • stärker als eine Assoziation (siehe weiter unten), aber schwächer als eine Komposition.
  • eine Beziehung der Art “besitzt ein/e”.
  • in ihrer Lebensdauer nicht an die Lebensdauer des Ganzen gebunden.

Beispiel 1: “Eine Taskforce hat Experten und -innen”. Das bedeutet, dass es die Experten und -innen immer noch gibt, wenn die Taskforce aufgelöst wird. Beispiel 2: “Ein Auto hat einen Fahrer oder eine Fahrerin”. Die Existenz des Fahrers / der Fahrerin ist nicht an die Existenz des Autos gebunden.

Darstellung der Aggregation mit UML:

In der Aggregation kann das zugeordnete Objekt unabhängig existieren. Es wird also nicht vollständig vom “Besitzer” (Container-Objekt) kontrolliert.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Aggregation: Ein Auto hat einen Fahrer, aber der Fahrer kann auch ohne Auto existieren
class Fahrer {

    String name;

    public Fahrer(String name) {
        this.name = name;
    }
}

class Auto {

    private Fahrer fahrer; // Aggregation: Auto hat einen Fahrer

    public Auto(Fahrer fahrer) {
        this.fahrer = fahrer;
    }
}

Hier kann der Fahrer unabhängig vom Auto existieren, was die lose Beziehung der Aggregation verdeutlicht.

Komposition Die Komposition ist

  • eine sehr starke Beziehung.
  • eine Beziehung der Art “ist ein Teil von” / “besteht aus”.
  • in ihrer Lebensdauer an die Lebensdauer des Ganzen gebunden.

Beispiel 1: “Ein Labyrinth hat Wände.” Eine Wand kann nur als Teil eines Labyrinths existieren. Beispiel 2: “Ein Mensch hat ein Herz.” Ein Mensch kann ohne Herz nicht existieren.

Darstellung der Komposition in UML:

In der Komposition existiert das “Teil”-Objekt nur im Kontext des “Ganzen”. Es kann nicht unabhängig existieren.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Komposition: Ein Haus besteht aus Räumen, die nur im Haus existieren
class Raum {

    private String name;

    public Raum(String name) {
        this.name = name;
    }
}

class Haus {

    private Raum raum; // Komposition: Haus besitzt Raum, und dieser existiert nur innerhalb des Hauses

    public Haus() {
        this.raum = new Raum("Wohnzimmer");
    }
}

Hier ist Raum vollständig in Haus eingebettet, und ohne das Haus gäbe es den Raum nicht, was die Komposition darstellt. Die Instanz von Raum wird direkt im Haus erstellt und existiert ausserhalb nicht.

Assoziation (KNOWS-A Beziehung)

Wir haben bereits zwei Formen von Assoziationen kennengelernt: Aggregation & Komposition. Wenn von einer Assoziation die Rede ist, so sind damit Objekte gemeint, welche miteinander auf irgendeine Weise in Beziehung stehen. Die Komposition ist die stärkste Form der Assoziation, die Aggregation ist etwas abgeschwächt und die Assoziation selbst ist die schwächste Beziehung. Der Begriff Assoziation ist hier etwas verwirrend, weil er gleichzeitig als Oberbegriff und als Verbindung benutzt wird.

Eine Assoziation ist

  • eine Beziehung der Art “benutzt ein/e”, “ist zugeordnet zu”, “hat eine Beziehung zu”.
  • auch unter der Bezeichnung KNOWS-A bekannt.

Beispiel: “Eine Musikerin spielt ein Instrument.” Sie “kennt” das Instrument, das sie spielt.

Darstellung der Assoziation in UML:

Die Assoziation beschreibt eine lose Beziehung zwischen Objekten, die miteinander “wissen” oder " kennen".

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Assoziation: Ein Musiker kennt das Instrument, das er spielt
class Instrument {

    private String name;

    public Instrument(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

class Musiker {

    private Instrument instrument; // Assoziation: Musiker kennt ein Instrument

    public Musiker(Instrument instrument) {
        this.instrument = instrument;
    }

    public void spielen() {
        System.out.println("Der Musiker spielt " + instrument.getName());
    }
}

Der Musiker kennt das Instrument, das er spielt, was die lose Assoziation verdeutlicht.

Gerichtete Assoziation

Eine gerichtete Assoziation ist eine Verbindung zwischen zwei Klassen, bei der die Beziehung eine klare Richtung hat.

Beispiel: Du hast zwei Klassen: Kunde und Bestellung. Es besteht eine gerichtete Assoziation von Kunde zu Bestellung. Das bedeutet, dass der Kunde eine oder mehrere Bestellungen kennt, Bestellung weiss aber nichts über den Kunden.

Eine gerichtete Assoziation zeigt eine Richtung in der Beziehung zwischen zwei Objekten.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Gerichtete Assoziation: Kunde kennt Bestellungen, aber Bestellungen kennen den Kunden nicht
class Bestellung {

    private String beschreibung;

    public Bestellung(String beschreibung) {
        this.beschreibung = beschreibung;
    }

    public String getBeschreibung() {
        return beschreibung;
    }
}

class Kunde {

    private Bestellung bestellung; // Kunde kennt eine Bestellung

    public Kunde(Bestellung bestellung) {
        this.bestellung = bestellung;
    }

    public void anzeigen() {
        System.out.println("Kunde hat Bestellung: " + bestellung.getBeschreibung());
    }
}

Hier “kennt” der Kunde die Bestellung, aber die Bestellung weiss nichts vom Kunden.

Abhängigkeit (USES Beziehung)

Eine Abhängigkeit ist

  • eine gerichtete Beziehung zwischen einem abhängigen (Client) und einem unabhängigen Element ( Supplier).
  • eine Beziehung, wo die eine Klasse die andere zum Funktionieren braucht.
  • schwächer als eine Assoziation.
  • möglich, ohne ein Objekt der Abhängigkeit dauerhaft zu speichern.

Die abhängige Klasse hat keine Instanzvariable vom Typ der unabhängigen Klasse. Es werden nur Parameter vom Typ der unabhängigen Klasse verwendet. Es ist auch möglich, eine Abhängigkeit ohne Objekte zu erstellen, zum Beispiel mit statischen Methoden.

Darstellung der Abhängigkeit in UML:

Eine Abhängigkeit zeigt, dass eine Klasse eine andere zum Funktionieren benötigt, aber nicht dauerhaft hält.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Abhängigkeit: Eine Klasse benutzt eine andere Klasse temporär, z.B. in einer Methode
class Drucker {

    public void drucken(String dokument) {
        System.out.println("Druckt Dokument: " + dokument);
    }
}

class Benutzer {

    public void benutzeDrucker(Drucker drucker) {
        drucker.drucken("Mein Bericht");
    }
}

In diesem Beispiel benötigt der Benutzer einen Drucker, um ein Dokument zu drucken, aber es gibt keine dauerhafte Beziehung, nur eine temporäre Verwendung. Es gibt also keine Instanzvariable des Typs Drucker in Benutzer, sondern nur eine Methode mit einem Parameter drucker, welcher nur temporär existiert.

Polymorphismus

Polymorphie bedeutet “Vielgestaltigkeit”. Die Polymorphie beschreibt ein Konzept der objektorientierten Programmierung, wobei der Aufruf einer Methode mit identischer Signatur unterschiedliche Ergebnisse liefern kann. Dieses Verhalten ist vorallem bei der Vererbung anzutreffen. In Java sind alle Objekte polymorph, da jedes Objekt eine IS-A Beziehung für seinen eigenen Typ und für die Klasse Object besitzt (alle Klassen erben von Object, Everything is an Object).

Eine Referenzvariable kann auf jedes Objekt ihres deklarierten Typs oder auf jeden Subtyp ihres deklarierten Typs verweisen.

Beispiel:

1
2
3
4
5
6
class Animal {

    public void move() {
        System.out.println("Animals can move");
    }
}
1
2
3
4
5
6
class Dog extends Animal {

    public void move() {
        System.out.println("Dogs can walk and run");
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class Main {

    public static void main(String args[]) {
        Animal animal = new Animal();
        Animal dog = new Dog();

        animal.move();
        dog.move();
    }
}

Die Ausgabe ist wie folgt:

Animals can move
Dogs can walk and run

In diesem Beispiel ist erkennbar, dass die Referenz dog (obwohl es sich um ein Animal handelt), die Methode move() der Klasse Dog ausführt. Der Grund dafür ist, dass während der Kompilierung der Referenztyp überprüft wird. Zur Laufzeit ermittelt die JVM jedoch das Objekt und führt die Methode aus, die zu der Klasse dieses Objekts gehört.

Overriding

Beim Überschreiben von Methoden wird eine Methode der Superklasse in einer Subklasse neu definiert. Eine Subklasse kann dadurch das Verhalten einer Methode der Superklasse anders spezifizieren. Das Überschreiben hat den Vorteil, dass ein Verhalten definiert werden kann, das für den Typ der Subklasse spezifisch ist. Die überschreibende Methode muss dieselbe Signatur (Methodenname; Anzahl, Typ und Reihenfolge der Parameter) aufweisen. Die zu überschreibende Methode darf nicht final sein.

Beispiel:

1
2
3
4
5
6
public class Shape {

    public double getArea() {
        return 0;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class Rectangle extends Shape {

    private double width;
    private double height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public double getArea() {
        return width * height;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class Circle extends Shape {

    private double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public double getArea() {
        return Math.pow(this.radius, 2.0) * Math.PI;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class Main {

    public static void main(String[] args) {
        Shape rectangle = new Rectangle(10.0, 5.0);
        Shape circle = new Circle(4.0);

        System.out.println("Area of the rectangle: " + rectangle.getArea());
        System.out.println("Area of the circle " + circle.getArea());
    }
}

Die Ausgaben sind nicht verwunderlich. Da es sich um Objekte des Typs Rectangle und Circle handelt, werden jeweils die überschriebenen Methoden aufgerufen. Wenn eine Subklasse ein bestimmte Methode nicht überschreibt, so wird die Methode der nächsthöheren Klasse (in diesem Falle der Klasse Shape) verwendet.

@Override

Die Annotation @Override weist den Compiler an, die Signatur der überschreibenden Methode zu überprüfen. Die Annotation ist optional, hilft aber in einfacher Weise Fehler beim Überschreiben zu verhindern. Wenn eine mit @Override gekennzeichnete Methode die Methode der Superklasse nicht korrekt überschreibt, generiert der Compiler einen Fehler.

Abstraktion

In der objektorientierten Programmierung bezieht sich Abstraktion darauf, dem Benutzer Funktionalität bereitzustellen, ohne die Details der Implementierung preiszugeben. Es ist also bekannt, was ein Objekt tun kann, aber nicht wie die Funktionalität genau umgesetzt ist. In Java wird Abstraktion mit Hilfe von abstrakten Klassen und Interfaces erreicht.

Abstrakte Klassen

Eine Klasse, die das Schlüsselwort abstract in ihrer Deklaration enthält, wird als abstrakte Klasse bezeichnet.

  • Abstrakte Klassen können beliebig viele abstrakte Methoden enthalten.
  • Eine abstrakte Methode besitzt keinen Block, sie muss in jedem Fall durch eine nicht-abstrakte Methode einer Klasse in der darunterliegenden Hierarchie überschrieben werden.
  • Eine abstrakte Klasse kann nicht instanziert werden, es ist also nicht möglich von einer solchen Klasse ein Objekt zu erstellen.
  • Abstrakte Klassen eignen sich, um gemeinsame Funktionalitäten von Subklassen aufzunehmen.

Beispiel:

  • Eine abstrakte Klasse Animal
  • Eine abstrakte Subklasse Carnivore (Fleischfresser)
  • Eine abstrakte Subklasse Herbivore (Pflanzenfresser)
  • Eine Subklasse Dog
  • Eine Subklasse Cat
  • Eine Subklasse Sheep
  • Eine Subklasse Cow

Alle diese Tiere sollen sich bewegen und unterschiedliche Geräusche machen können. Die Methoden move() und sound() bewerkstelligen dies.

1
2
3
4
5
6
7
8
public abstract class Animal {

    public void move() {
        System.out.println("Animal is moving");
    }

    public abstract void sound(); //Die abstrakte Methode sound() hat keinen Body
}
1
2
3
public abstract class Carnivore extends Animal {
    // some carnivore specific stuff
}
1
2
3
public abstract class Herbivore extends Animal {
    // some herbivore specific stuff
}
1
2
3
4
5
6
7
public class Dog extends Carnivore {

    @Override
    public void sound() {
        System.out.println("Woff Woff...");
    }
}
1
2
3
4
5
6
7
public class Cat extends Carnivore {

    @Override
    public void sound() {
        System.out.println("Meow Meow...");
    }
}
1
2
3
4
5
6
7
public class Sheep extends Herbivore {

    @Override
    public void sound() {
        System.out.println("Baa Baa...");
    }
}
1
2
3
4
5
6
7
public class Cow extends Herbivore {

    @Override
    public void sound() {
        System.out.println("Moo Moo...");
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class Main {

    public static void main(String args[]) {
        Animal dog = new Dog();
        Animal cat = new Cat();
        Animal sheep = new Sheep();
        Animal cow = new Cow();

        dog.sound();
        dog.move();

        cat.sound();
        cat.move();

        sheep.sound();
        sheep.move();

        cow.sound();
        cow.move();
    }
}

Zusammenfassung:

  • Alle Tiere können sich bewegen. Als ein gemeinsames Merkmal ist dies in der Klasse Animal implementiert.
  • Fleischfresser und Pflanzenfresser könnten in den jeweiligen Klassen spezifische Implementationen bereitstellen.
  • Alle Tiere machen unterschiedliche Geräusche und aus diesem Grund wird die Methode sound() in der Klasse Animal als abstract deklariert, so dass alle untergeordneten Klassen diese Methode auf ihre eigene Weise implementieren müssen.

Eine weitere wichtige Lektion ist die Polymorphie in der Klassenhierarchie. Eine Katze ist gemäss Definition ein Fleischfresser. Das folgende Beispiel soll die mögliche Typenumwandlung erklären.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class Main {

    public static void main(String args[]) {
        Cat cat = new Cat();
        Carnivore carnivoreCat = cat;     // Möglich, da eine Katze ein Fleischfresser ist
        Animal animalCat1 = cat;          // Möglich, da eine Katze ein Tier ist
        Animal animalCat2 = carnivoreCat; // Möglich, da ein Fleischfresser ein Tier ist

        Animal animalDog = new Dog();
        Carnivore carnivoreDog1 = animalDog;            // Nicht möglich, da ein Tier kein Fleischfresser ist
        Carnivore carnivoreDog2 = (Carnivore) animalDog; // Mit Cast-Operator möglich
        Dog dog1 = animalDog;                           // Nicht möglich, da ein Tier kein Hund ist
        Dog dog2 = (Dog) animalDog;                      // Mit Cast-Operator möglich
        Dog dog3 = carnivoreDog2;                       // Nicht möglich, da ein Fleischfresser kein Hund ist
        Dog dog4 = (Dog) carnivoreDog2;                  // Mit Cast-Operator möglich
    }
}

Wie wir sehen ist die Umwandlung in einen Typ, welche höher in der Klassenhierarchie liegt, stets ohne Cast-Operator möglich. Bei der Umwandlung in einen unterliegenden Typ (Downcasting) muss der Cast-Operator zwingend implementiert werden. Zur Laufzeit kann es beim Downcasting jedoch zu einer ClassCastException kommen, wenn die Referenz kein Objekt des gecasteten Typs ist.

instanceof Operator

Durch den Einsatz des Operators instanceof kann zur Laufzeit die Referenz eines Objektes auf einen bestimmten Typ überprüft werden.

Beispiel:

1
2
3
4
5
6
7
8
9
public class Main {

    public static void main(String args[]) {
        Animal dog = new Dog();
        if (dog instanceof Dog) {
            // ...
        }
    }
}

Der Operator überprüft also den Typ einer Instanz und berücksichtigt dabei Subklassen und Interfaces.

Interfaces

Ein Interface dient dem Angebot von Methoden, die durch Klassen zu implementieren sind, welche das Interface “implementieren”. Damit definiert ein Interface einen Satz von bestimmten Funktionen, die allen implementierenden Klassen des Interfaces gleich sind. Ein Interface muss dabei nicht zwingend eine Methode enthalten. Dies ist z.B. der Fall beim Interface Serializable. Dieses sagt lediglich semantisch aus, dass eine Klasse resp. das Objekt davon serialisiert werden kann.

Eine Schnittstelle hat im Unterschied zu einer Klasse weder ein Verhalten noch einen Status – wir können ein Interface als einen Vertrag betrachtet, den eine Klasse erfüllen muss. Ein Interface besitzt anstelle der Klassendefinition das Schlüsselwort interface.

Ein Interface kann die folgenden Dinge enthalten:

  • Konstanten, also public static final Variablen, wobei die Schlüsselwörter nicht erforderlich sind.
  • public abstract Methoden, wobei die Schlüsselwörte nicht erforderlich sind.
  • Normale Methoden mit Implementierung (das Schlüsselwort default ist erforderlich) seit Java 8.
  • Statische Methoden mit Implementierung (das Schlüsselwort static ist erforderlich) seit Java 8.

Ein Interface darf die folgenden Dinge nicht enthalten:

  • Instanzvariablen
  • Konstruktoren
  • Nicht-öffentliche abstrakte Methoden
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public interface Vehicle {

    double MILES_PER_KM = 1.60934;

    String getBrand();

    String speedUp();

    String slowDown();

    static double parseToKmh(double mph) {
        return mph * MILES_PER_KM;
    }

    static double parseToMph(double kmh) {
        return kmh / MILES_PER_KM;
    }

    default String turnAlarmOn() {
        return "Turning the vehicle alarm on.";
    }

    default String turnAlarmOff() {
        return "Turning the vehicle alarm off.";
    }
}

Eine Klasse verwendet in ihrer Deklaration das Schlüsselwort implements, um eine Schnittstelle zu implementieren. Ein Interface verwendet jedoch das Schlüsselwort extends, um eine andere Schnittstelle zu erweitern.

Beispiel:

  • Eine Basisklasse mit dem Namen Bird
  • Eine Subklasse mit dem Namen Parrot
  • Eine Subklasse mit dem Namen Penguin
  • Ein Interface mit dem Namen Flyable

1
2
3
4
5
6
public class Bird {

    public void eat() {
        System.out.println(getClass().getSimpleName() + " is eating!");
    }
}
1
2
3
4
public interface Flyable {

    void fly();
}
1
2
3
4
5
6
7
public class Parrot extends Bird implements Flyable {

    @Override
    public void fly() {
        System.out.println("Parrot is Flying!");
    }
}
1
2
3
public class Penguin extends Bird {

}

Wie wir sehen ist die Klasse Parrot gezwungen den Vertrag mit dem Interface Flyable zu erfüllen. Der Vorteil dieser Implementation wird erst ersichtlich, wenn das Interface beispielsweise als Parameter verwendet wird. Nur Instanzen von Klassen, welche das Interface implementieren, können als Parameter verwendet werden.

1
2
3
4
5
6
public class Birdhouse {

    public void arrive(Flyable flyable) {
        flyable.fly();
    }
}
1
2
3
4
5
6
7
8
public class Main {

    public static void main(String[] args) {
        Flyable parrot = new Parrot();
        Birdhouse birdhouse = new Birdhouse();
        birdhouse.arrive(parrot);
    }
}

Im Beispiel sehen wir, dass durch die Verwendung eines Interfaces die Abhängigkeiten zwischen den Klassen Birdhouse und Parrot vollständig aufgehoben wird. Beide Klassen kennen einander nicht, dies wird Entkopplung genannt. Dem Vogelhaus ist es also egal, welcher Vogel ankommt. Er muss aber fliegen können.

Komposition vor Vererbung

“Komposition vor Vererbung” ist ein Prinzip in der objektorientierten Programmierung (OOP), das empfiehlt, Komposition gegenüber Vererbung zu bevorzugen, um Systeme zu entwerfen. Dieses Prinzip zielt darauf ab, die Einschränkungen der Vererbung zu überwinden und flexibleren sowie wartbareren Code zu ermöglichen.

Lass uns die beiden Konzepte näher betrachten:

Vererbung

Vererbung ist ein Mechanismus, bei dem eine Klasse (die Unterklasse oder abgeleitete Klasse) Attribute und Methoden von einer anderen Klasse (der Oberklasse oder Basisklasse) erbt. Vererbung ermöglicht die Wiederverwendung von Code und stellt Beziehungen wie “ist-ein” (z. B. ein Hund ist ein Säugetier) her.

Die Vererbung kann jedoch einige Probleme verursachen:

  • Enge Kopplung: Unterklassen sind eng mit ihren Oberklassen verbunden, was bedeutet, dass Änderungen in der Oberklasse alle abgeleiteten Klassen beeinflussen können.
  • Starre Hierarchie: Vererbung schafft eine strenge Hierarchie, die oft unflexibel ist. Es wird schwieriger, neue Funktionalitäten hinzuzufügen, ohne bestehende Strukturen zu ändern. Dies kann gegen das Open-Closed-Prinzip (OCP) des SOLID-Designs verstossen.
  • Übernutzung führt zu Zerbrechlichkeit: Zu viel Vererbung kann zu fragilen und schwer wartbaren Codebasen führen. Änderungen in einer Oberklasse können unvorhergesehene Konsequenzen für alle abgeleiteten Klassen haben.

Komposition

Komposition hingegen bezieht sich auf die Praxis, eine Klasse durch den Einsatz anderer Klassen zu " komponieren", anstatt Vererbung zu verwenden. Das bedeutet, dass eine Klasse Objekte von anderen Klassen als Instanzvariablen enthält, um Funktionalität wiederzuverwenden. Dies folgt dem “hat-ein” -Prinzip (z. B. ein Auto hat einen Motor).

Vorteile der Komposition:

  • Flexibilität: Komposition ermöglicht es, Objekte dynamisch zu kombinieren oder zu ändern, was mehr Flexibilität bei der Strukturierung eines Programms bietet.
  • Geringere Kopplung: Da Klassen nicht voneinander erben, sind sie weniger eng miteinander verbunden. Änderungen in einer Klasse wirken sich nicht auf die andere aus.
  • Erleichtert das Testen: Da Klassen unabhängiger sind, wird das Testen vereinfacht, insbesondere bei der Verwendung von Mock-Objekten oder Stubs.

Beispiel

Anstatt eine Klasse Vogel von einer Klasse Tier zu erben, könnte man eine Klasse Fliegen als Komponente verwenden:

Vererbung:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Vogel extends Tier {

    void fliegen() {
        // Fluglogik
    }
}

class Kiwi extends Vogel {
    @Override
    void fliegen() {
      // Der Kiwi kann nicht fliegen, dies muss hier behandelt werden.
    }
}

Das Problem hier ist, wenn es ein Vogel gibt, welcher nicht fliegen kann, wie zum Beispiel ein Kiwi. Der Kiwi ist ein Vogel, erbt also von Vogel, kann aber nicht fliegen. Die Methode fliegen() wird aber trotzdem vererbt, was nicht korrekt ist.

Komposition:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Kiwi {

    private Fliegen flugVerhalten;

    public Vogel(Fliegen flugVerhalten) {
        this.flugVerhalten = flugVerhalten;
    }

    void fliegen() {
        flugVerhalten.fliegen();
    }
}

oder mit Interface

1
2
3
4
5
6
7
8
9
interface Flyable {
    void fliegen();
}

class Kiwi implements Flyable {
    void fliegen() {
        //Implementation
    }
}

Mit Komposition könnte man nun verschiedene Flugverhalten einfach austauschen, ohne die Vogelklasse zu ändern, was sie flexibler und wartbarer macht.

Zusammengefasst fördert das Prinzip “Komposition vor Vererbung” eine flexiblere und modularere Softwarearchitektur und hilft dabei, die Nachteile von starrer Vererbung zu vermeiden.

Kapselung

Kapselung ist eines der bedeutendsten Konzepte der objektorientierten Programmierung, welches Sicherheit bietet, indem es die sensiblen Daten/Implementierungsdetails einer Klasse vor den Benutzern verbirgt.

In Java kann die Kapselung erreicht werden, indem die Klassenattribute/-variablen als privat deklariert werden. Die Klasse stellt dann öffentliche Methoden zur Verfügung, welche von “aussen” ( durch andere Klassen) verwendet werden können, um bestimmte Information zu erhalten oder um bestimmten Operationen, welche auf den Attributen der Klasse basiert sind, ausführen zu können.

Beispiel

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Student {

    private final String name;
    private final String lastName;
    private final int age;
    private int index = 0;
    private final String[] courses = {};

    public Student(String name, String lastName, int age) {
        this.name = name;
        this.lastName = lastName;
        this.age = age;
    }

    public void enroleCourse(String course) {
        courses[index++] = course;
    }

    @Override
    public void toString() {
        System.out.printf("Name: %s\nLastname: %s\nAge: %d\n", name, lastName, age);
        System.out.println("Courses:");
        Arrays.stream(courses).forEach(System.out::println);
    }
}

Die Student Klasse beinhaltet vier private Attribute, worauf der Benutzer dieser Klasse keinen Zugriff hat. Die Klasse stellt neben dem Konstruktor lediglich zwei öffentliche Methoden zur Verfügung. Die interne Struktur der Student Klasse bleibt vom Benutzer verborgen. So weiss der Benutzer z.B. nicht, dass die Liste der Courses mit einem Array umgesetzt worden ist. Dies erlaubt eine Strukturänderung innerhalb der Student Klasse, ohne dass der Benutzer etwas davon merkt oder seinen Code ändern muss (die öffentlichen Methoden ändern sich nicht):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Student {

    private final String name;
    private final String lastName;
    private final int age;
    private final List<String> courses; // use List instead of Array

    public Student(String name, String lastName, int age) {
        this.name = name;
        this.lastName = lastName;
        this.age = age;
    }

    public void enroleCourse(String course) {
        courses.add(course);
    }

    @Override
    public void toString() {
        System.out.printf("Name: %s\nLastname: %s\nAge: %d\n", name, lastName, age);
        System.out.println("Courses:");
        courses.forEach(System.out::println);
    }
}

Die Student Klasse erlaubt zusätzlich keinen direkten Zugriff auf ihre Attribute (es gibt keine Getter-Methoden). Die Überlegung hier ist, dass die einzelnen Attribute niemanden ausserhalb der Student Klasse interessieren. Von aussen will man lediglich dem Student einen Kurs zuweisen können und alle Informationen zum Studenten ausgeben.

Das Befolgen des Kapselung-Prinzips führt zu einem Design, welches folgende Vorteile mit sich bringt:

  • Die Attribute und damit der Zustand einer Klasse bzw. eines Objektes sind vor “fremdem” Zugriff geschützt (Data-Hiding).
  • Die Klasse hat eine öffentliche API, welche von Benutzern der Klasse verwendet werden kann. Somit ist auch klar definiert, was die Aufgabe dieser Klasse ist.
  • Die öffentliche API einer Klasse ermöglicht das Verbergen von Umsetzungsdetails. Somit haben interne Strukturänderungen dieser Klasse keinen Einfluss auf den Code des Benutzers.

task1 Jetzt bist du dran. Löse bitte die Aufgaben in den Labs.

Last modified October 21, 2024: merge master into branch (50026fb32)