Assoziationen  —  Beziehungen zwischen Klassen

1. Einstieg

Bisher haben wir nur isolierte Objekte betrachtet und verwendet. Es wurden Objekte einer Klasse erzeugt und ihre Methoden aufgerufen.

Das kann aber natürlich nicht alles sein. Die reale Welt ist durch ununterbrochene Interaktion zwischen Objekten geprägt. Zur Erstellung nützlicher objektorientierter Software, die Objekte als dem Menschen vertraute Konzepte (in sehr vereinfachter Form) nachbildet, ist daher ebenfalls die Möglichkeit zur Objekt-Interaktion vorgesehen.

Um mit anderen Objekten interagieren zu können, ist eine Möglichkeit nötig, in Kontakt zu treten - so etwas wie eine Hauptspeicher-Adresse oder sonstige "Kontaktdaten".

Eine sehr häufige Form, solche "Kontaktdaten" zu verwalten, sind in der Klassenstruktur festgelegte "Kontakt"-Eigenschaften, also Instanzvariable. Man hat also Attribute/Eigenschaften/Instanzvariable, die ein anderes Objekt (eigener oder anderer Klasse) referenzieren, also strukturell eine Verbindung vorsehen. Das nennt man Assoziation.

Wir werden sehen, dass jede Klasse auch als Typ – nicht nur für Eigenschaften/Instanzvariablen, sondern auch sonstige Variablen (lokal, Parameter) und als Rückgabetyp von Methoden – verwendet werden kann (wird dann Abhängigkeit/Referenzierung/Dependency genannt).

Wenn wir z.B. eine einfache Klasse Adresse haben:

Adresse.java:
public class Adresse {
    private int plz;
    private String ort;
    private String strasse;

    public Adresse(int plz, String ort, String strasse) {
        setPlz(plz);
        setOrt(ort);
        setStrasse(strasse);
    }
    // dazu die Getter und Setter

    public String toString() {
        return plz + " " + ort + ", " + strasse;
    }
    public void printInfo(String titel) {
        System.out.println("  -- " + titel + " --");
        System.out.println("  PLZ: " + plz);
        // ...
    }
}

und weiters eine Klasse Person, die eine Wohnadresse haben soll, können wir schreiben:

Person.java:
public class Person {
    private String name;
    private char geschlecht;
    private int gebJhr;
    private Adresse wohnAdr;  // <--- Referenzvariable, KANN auf ein Adress-Objekt zeigen

    public Person(String name, char geschlecht, int gebJhr, Adresse wohnAdr) {
        setName(name);
        setGeschlecht(geschlecht);
        setGebJhr(gebJhr);
        setWohnAdr(wohnAdr);
    }
    public void setName(String name) {
        this.name = name;
    }

    public void setGeschlecht() {
        this.geschlecht = geschlecht;
    }
    public void setGebJhr() {
        this gebJhr = gebJhr;
    }
    public void setWohnAdr(Adresse wohnAdr) {   // <-- neu: Adresse
      this.wohnAdr = wohnAdr;
    }
    public String getName() {
        return this.name;
    }
    public char getGeschlecht() {
        return this.geschlecht;
    }
    public int getGebJhr() {
        return this.gebJhr;
    }
    public Adresse getWohnAdr() {   // <-- neu: Adresse
        return this.wohnAdr;
    }
    public String toString() {
        return "Person [name=" + name + ", Geschlecht=" + geschlecht
                + ", GebJhr=" + gebJhr + ", WohnAdr:" + wohnAdr.toString();
    }
}

Es fällt auf, dass gar nichts Auffälliges zu sehen ist. Strukturell ist überhaupt kein Unterschied da – an bestimmten Stellen sehen wir einfach den Klassennamen Adresse, wo wir bisher int, boolean oder String gehabt hätten.

Wenn man nun den Datentyp String ansieht, fällt einem auf, dass er als einziger bisher verwendeter Datentyp mit großem Anfangsbuchstaben geschrieben wird - was wir als Kennzeichen werten müssen, dass es ein Klassennamen sein muss – wir schreiben ja nichts Anderes mit großem Anfangsbuchstaben.

Tatsächlich ist es so, dass wir mit Strings schon von Beginn weg mit Objekt-Datentypen zu tun gehabt haben – in Java sind Strings Objekte, haben Methoden, etc., was wir bald genauer betrachten werden.

Diese als Instanzvariable definierten Objektreferenzen sind unscheinbar, bringen aber explosiv wachsende programmtechnische Möglichkeiten mit sich – nun können wir beliebig komplexe Systeme von miteinander in Beziehung stehenden Objekten definieren!

2. Objekt-Zugriff per Referenz

Eine Objektvariable speichert nicht das Objekt selbst, sondern nur seine Adresse – analog zu einem Web-Link, der ebenfalls nicht die verlinkte Seite enthält, sondern eine eindeutige Information, wo/wie die verlinkte Seite zu finden und aufzurufen ist.

  • Eine Objektvariable steht für ein Objekt (wenn auch nur in Form einer Referenz) – daher können wir damit auch Methoden am referenzierten Objekt ausführen. Man schreibt einfach z.B.: wohnAdr.getPlz() (faktisch ersetzt man das this, das in Wahrheit eine Referenz auf das aktuelle Objekt ist, durch die Referenz auf ein anderes Objekt).

3. Ungenutzte Objekt-Referenzen enthalten 'null'

  • Eine Objektvariable verweist nicht zwingend auf ein Objekt - sie kann auch "ins Leere" zeigen. Dafür gibt es in Java ein eigenes Schlüsselwort: null.

  • Man kann einer Objektvariablen auch explizit den Wert null zuweisen, um sie gezielt ins Leere zeigen zu lassen:

    wohnAdr = null;
  • Es ist oft erforderlich, zu prüfen, ob die Variable zur Zeit ins Leere zeigt:

    if (wohnAdr == null) { ... }
    // oder Prüfung, ob gültiger Verweis auf Objekt besteht:
    if (wohnAdr != null) { ... }  // UNGLEICH null
  • Wenn man versucht, an einer Objektvariablen, die aktuell ins Leere zeigt, also null enthält, eine Methode aufzurufen, findet diese Methode kein Objekt vor und kann daher auch nicht auf seine Instanzvariablen und Methoden zugreifen.
    Die Folge ist eine NullPointerException.
    (Exceptions sind ein sehr leistungsfähiges Konzept, mit Ausnahmesituationen während des Programmlaufes umzugehen) – daran kann man wunderbar erkennen, dass man im Code entweder auf das Zuweisen eines Objekts oder auf die oben erwähnte null-Prüfung vergessen hat.

4. Objekterzeugung im Code

Bisher haben wir Objekte meistens direkt in BlueJ per Rechtsklick auf das jeweilige Klassensymbol und Auswahl eines der Einträge "new + einer der Konstruktoren" erzeugt.

Neue Objekte einer Klasse können jedoch jederzeit auch innerhalb einer anderen Klasse (das ist der Regelfall) neu erzeugt werden.

Das erzeugte Objekt wird sehr oft "gespeichert", indem man die von "new" zurückgelieferte Objekt-Referenz (quasi eine "URL" auf die Stelle, an der das erzeugte Objekt im Hauptspeicher (RAM) liegt) einer für den gegebenen Typ vorgesehenen Objektvariablen zuweist.

Das geht (hier für eine gedachte Klasse Person) konkret so:

Person p1 = new Person("Evi", 'w', 1999);

An der so präparierten Objektvariablen p1 lassen sich nun alle in Klasse Person definierten Methoden (und Attribute) aufrufen (innerhalb von anderen Klassen nur die 'public' Methoden bzw. Attribute):

System.out.println("Evis Geburtsjahr ist " + p1.getGebJahr());

5. Beispiel

Im hier gezeigten Beispiel wird ein Objekt der Klasse Adresse innerhalb der Klasse Person erzeugt:

public class Person {
    // ...
    private Adresse wohnAdr = null;  // null ist Default-Wert                (1)
    // ...
    public void demoMethode() {
      // ...
      wohnAdr = new Adresse(9999, "Abseitskirchen", "Sackgasse 99");         (2)
      Adresse zweitAdr = new Adresse(1357, "Wien", "Nimmergasse 0");         (3)
      // ...
    }
    public void createAndSetWohnAdr(int plz, String ort, String strasse) {   (4)
        Adresse adr = new Adresse(plz, ort, strasse);
        setWohnAdr(adr);
    }
    public void createAndSetWohnAdr2(int plz, String ort, String strasse) {  (5)
        setWohnAdr(new Adresse(plz, ort, strasse)); // gleicher Effekt!
    }
    public void setWohnAdr(Adresse wohnAdr) {
        this.wohnAdr = wohnAdr;
    }
    public Adresse getWohnAdr() {
        return this.wohnAdr;
    }
}

+

1 Instanzvariable vom Typ Adresse – zeigt derzeit ins Leere (Wert null)
2 Zuweisung eines neu erzeugten Adresse-Objekts an das Attribut wohnAdr
3 Erzeugen einer lokalen Objektvariablen adr und sofortige Zuweisung eines neu erzeugten Adresse-Objekts
4 Methode, die alle Daten für die Erzeugung eines Adresse-Objekts als Parameter erhält, damit ein neues Adresse-Objekt erzeugt und dieses dem Setter übergibt. So hat die Person nach Ausführen dieser Methode eine gültige wohnAdr.
5 Alternativ: Kompaktere Version mit gleichem Effekt (ohne Umweg über lokale Variable adr). Damit wird die Methode quasi ein "Einzeiler".

+ * Klassen können auch als Rückgabetyp einer Methode dienen – siehe oben den Getter: public Adresse getWohnAdr() { …​ }. Dann wird ein Objekt deses Typs zurückgeliefert.

  • Klassen können auch als Typ eines Methodenparameters auftreten – siehe oben den Setter: public void setWohnAdr(Adresse wohnAdr) { …​ }.

  • bei der Parameter-Übergabe an Methoden wird immer der gerade enthaltene Inhalt kopiert.
    Bei primitiven Datentypen ist der Inhalt tatsächlich der Wert, bei Objektvariablen ist es allerdings quasi die Adresse. Die Kopie einer Adresse zeigt immer noch auf DAS SELBE "Haus". Änderungen des übergebenen Objekts innerhalb der Methode bleiben also auch nach Verlassen der Methode wirksam (bei primitiven Datentypen wird nur die Kopie geändert, die bei Verlassen der Methode verschwindet).

6. Null-Check vor Methodenaufruf

Aufruf einer Methode an einer Objektreferenz erfordert ZWINGEND, dass die Objektreferenz NICHT ins Leere zeigt (Wert null enthält).

Anderenfalls wird eine NullPointerException geworfen (Englischer Begriff: to throw an exception).

Da beispielsweise jede Objektvariable nach Deklaration ins Leere zeigt, kann man leicht vergessen, einen Wert zuzuweisen. Auch wenn eine Methode einen Objekt-Typ als Rückgabewert hat, könnte null zurückgegeben werden, wenn dies in der Javadoc-Dokumentation nicht ausdrücklich ausgeschlossen ist.

Daher ist immer dann, wenn die Möglichkeit besteht, dass eine Objektreferenz ins Leere zeigt, vor dem Aufruf einer Methode an diesem Objekt zu prüfen, ob es ungleich null ist:

Person evi;  // wurde vergessen:    = new Person("Evi", 'w', 1999);
if (evi != null) {
    System.out.println("Evis Geburtsjahr: " + evi.getGebJahr());
}

Wenn die null-Prüfung nicht erfolgen würde, hätten wir eine NullPointerException erhalten

--- in Arbeit ---

7. Einsatz bei "Utility"-Klassen

Sehr praktisch wäre eine Möglichkeit, die Bildchirmausgabe mit System.out.println(…​) u.ä. kompakter zu schreiben. Das geht, indem wir eine Hilfsklasse anlegen, die für mehrere Klassen diese Möglichkeit umsetzt. Wir nennen sie, weil das so schön kompakt und prägnant ist, Util.java:

Util.java
public class Util {
    public void prn(String txt) {
        System.out.println(txt);
    }
    public void pr(String txt) {
        System.out.print(txt);  // endet nicht mit Zeilenschaltung, ermöglicht 'Zusammenbauen' einer Zeile
    }
    public void pn() {          // ohne Text-Parameter!
        System.out.println();   // erzeugt nur eine Leerzeile
    }
}

Diese können wir folgendermaßen verwenden:

public class Person {
    // ...
    private Util u = new Util();  // Name absichtlich so kurz, damit die Schreibweise kompakt bleibt
    private Adresse wohnAdr;
    // ...
    public printInfo() {
        u.prn("===== Person =====");
        u.prn("Name:   ");
        u.prn("GebJhr: ");
        wohnAdr.printInfo("Wohnadresse");
        u.prn("^^^^^^^^^^^^^^^^^^");
    }
}

Im UML-Klassendiagramm sieht das so aus:

Utilprn(txt: String)pr(txt: String)pn() // nur Leerzeile ohne TextAdresseort: Stringplz: shortstrasse: StringKonstruktor(ort: String, plz: short, strasse: String)...()toString(): StringPersonu: Utilname: StringgebJhr: shortwohnAdr: Adresse...()printInfo()

Man erspart sich damit eine Menge Tipp-Arbeit und die Lesbarkeit ist mindestens genauso gut.

Die Beziehung zwischen Util und Person ist aber keine echte Assoziation, sondern eine "Dependency" (Abhängigkeit). Diese wird im UML-Diagramm mit strichlierter Linie (samt Pfeil auf die "genutzte" Klasse) dargestellt (siehe obiges Diagramm)

Nachteil dieses Ansatzes ist, dass in JEDEM Objekt ein eigenes Util-Objekt erzeugt wird. Bald werden wir eine Möglichkeit kennenlernen, nur ein einziges solches Objekt je Klasse erzeugen zu müssen (Instanzvariable → static Variable) oder sogar überhaupt darauf verzichten zu können (statische Util-Methoden, eventuell mit zusätzlich statischem Import)

8. Einsatz bei Test-Klassen

Das Testen eines Programms ist vermutlich genauso wichtig wie das Schreiben, da Code praktisch nie fehlerfrei ist. Nützlich ist ein Programm aber nur dann, wenn es (meistens) das Richtige tut.

Je komplexer ein Programm wird, desto öfter sind Korrekturen/Erweiterungen nötig. Da in komplexeren Programmen bei JEDER Änderung neue Fehler auch an unerwarteten Stellen auftreten können, muss IMMER hinterher neu getestet werden.

Daher ist es extrem wichtig, auf Knopfdruck mit gut überlegten Test-Fällen testen zu können.

Ein erster Schritt ist das Implementieren einer Test-Methode in jeder Klasse, die andere Methoden mit verschiedenen, gut überlegten Parametern aufruft und das Ergebnis samt Info ausgibt.

Praktischer ist es allerdings oft, eine eigene Test-Klasse zu schreiben, die Objekte verschiedener Klassen erzeugt und gemeinsam testet. Das können wir auf Basis der neu gelernten Assoziationen jetzt auch tun:

public class PersonUndAdresseTest {
    // ...
    private Util u = new Util();
    private Adresse wohnAdr1 = new Adresse(2000, "Stockerau", "Weitweg 7");
    private Adresse wohnAdr2 = new Adresse(2100, "Korneuburg", "Distelpfad 1");
    private Person p1 = new Person("Udo", 1999, null);  // absichtlich keine Adresse: 'null'
    private Person p2 = new Person("Evi", 2003, wohnAdr2); // oben definierte Adresse
    private Person p3;   // enthält noch 'null'

    public void testen1() {
        u.prn("WohnAdr1:");
        wohnAdr1.printInfo()
        u.pn();
        p1.printInfo();
        u.pn()
        u.prn("Testen Person 3:");
        p3 = new Person("Edi", 1987, new Adresse(1010, "Wien", "Zentrum-Platz 1")); // direkt erzeugt
        p3.printinfo();
        p3.irgendeineAndereMethode();
        // ...
        Person p4 = new Person("Ada", 1975, null);  // lokale Variable p4
        p4.printInfo();
    }
}

Später werden wir noch leistungsfähigere Test-Konzepte (JUnit u.ä.) kennenlernen, in denen automatisch das erwartete Ergebnis mit dem tatsächlich gelieferten auf Übereinstimmung verglichen wird. Damit können dann auf Knopfdruck auch große Mengen an Tests ohne Sichtkontrolle durch Software-Entwickler erfolgen.

Ein besonders plakatives Beispiel dafür ist das Java-SDK (= Software Development Kit), das die Basis hinter allen Entwicklungsumgebungen und indirekt sogar hinter allen Java-Programmen bildet.

Eine einzige fehlerhafte Verhaltensänderung im Zuge von Fehlerkorrekturen oder Weiterentwicklung würde weltweit alle Entwickler treffen – hier muss daher vor Freigabe JEDER Änderung ein seit 20 Jahren stetig wachsender Satz von Millionen Testfällen durchlaufen werden.

Das ist ohne vollständige Automatisierung unmöglich zu schaffen und zu finanzieren.