Kapitel 5 Vererbung
Zur UML-Spezialisierung des letzten Kapitels wird nun die zugehörige Java-Technologie vorgestellt.
Schwerpunkte bilden die Unterschiede von Overriding zu Overloading bzw. Shadowing, der Einfluss von Modifikatoren auf die Vererbung sowie Objekt-Anlage und -Zerstörung mittels Konstruktoren, Initialisierung und Finalization.
Vererbung, Modifikatoren und Objekt-Erzeugung sind mit wichtigen Regeln verbunden, die anhand von Beispielen, Diagrammen und Idiomen erklärt werden.
Spezialisierung wird in Java mittels Vererbung (Inheritance) realisiert.
Innerhalb der Klassenhierarchie werden generelle Klassen als Ober- oder Superklassen bezeichnet. Wird aus einer Klasse eine spezielle abgeleitet, wird diese direkte Unterklasse bzw. Subklasse genannt.
Subklasse vs.
echte Subklasse
Da in Java genau genommen jede Klasse Subklasse von sich selbst ist, müssten alle anderen echte oder strikte Subklassen genannt werden. Im Weiteren wird aber der Einfachheit halber unter einer Super- bzw. Subklasse immer eine echte bzw. strikte verstanden.
5.1 Deklaration von Subklassen
Mit Hilfe des Schlüsselwortes extends realisiert Java Subklassen als einfache Vererbung (single inheritance), d.h., zu einer Subklasse gehört nur genau eine Superklasse.
Deklaration einer Subklasse
Dagegen ist die Implementierung von beliebig vielen Interfaces mittels des Schlüsselworts implements möglich1 :
[cModifiers] class SubClassName
[extends SuperClass]
[implements Interface1, ...]
{...}
Die Prinzipien der Generalisierung/Spezialisierung (siehe 4.1), insbesondere die Substitutions-Regel und die dynamische Polymorphie, werden durch die Vererbung in Java wie folgt realisiert.
Subklassen erben Instanz-Variable der Superklasse. |
|
Abbildung 5.1 Die Subklasse D erbt Instanz-Variable von B
Es sei D eine direkte Subklasse von B (siehe Abb. 5.1). Dann gilt folgende Regel:
Zugriff auf Member der Superklasse
Vererbungs-Regel: Die Klasse D, und somit jede Instanz von D, »erbt« alle Instanz-Variablen von B. D kann direkt auf alle statischen oder nicht statischen Variablen und Methoden zugreifen, die in B nicht private erklärt sind.
Beispiel (zu Abb. 5.1)
class B {
static String bs= "static";
private char bc= 'b'; // wird vererbt, aber ohne Zugriff
int bi=2
static String bsf() { return bs+" bsf"; }
private char bpf() { return Character.toUpperCase(bc); }
String bf() { return String.valueOf(bc) + bpf()+ bi; }
}
class D extends B {
void test() { // Zugriff auf (nicht) statische
Member
System.out.println(bs+","+bi+","+bsf()+","+bf());
// System.out.println(bc + "," + bpf()); ß C-Fehler
}
}
Der Aufruf von test() über eine Instanz von D ergibt dann:
new D().test(); // :: static,2,static bsf,bB2
Obwohl in D keine Member angelegt werden (die Klasse scheint also leer zu sein), erbt sie alle Instanz-Variablen von B und kann auf alle (nicht private) Member von B zugreifen, als wären sie in D selbst deklariert.
Die o.a. Regel gilt nicht nur für eine direkte Superklasse, sondern auch für eine Klassen-Hierarchie.
Vererbung ist transitiv
|
Die Member-Vererbung ist transitiv. |
Abbildung 5.2 Vererbungs-Hierarchie
Ist D Subklasse von Superklassen Bi, so enthält D alle Instanz-Variablen der Superklassen Bi (siehe Abb. 5.2).
Die Klasse D kann auch auf alle (nicht private) Member der Superklassen Bi zugreifen, wobei dies allerdings uneingeschränkt nur für die direkte Superklasse B1 gilt (siehe hierzu 5.8.3).
Die Substitutions-Regel aus dem vierten Kapitel führt zur
Widening-Conversion-Regel: Referenzen bzw. Instanzen von D können überall da eingesetzt werden, wo als Typ irgendeine Superklasse B erwartet wird.
Referenzen von Superklassen zeigen auf alle Instanzen in der Klassen-Hierarchie
Insbesondere kann
|
eine Referenz vom Typ B auf eine Instanz von D verweisen: |
B b= new D();
|
ein Parameter vom Typ B durch ein Argument von D ersetzt werden: |
method(new D());
// Methode ist deklariert als: void method(B b) {...}
Erklärung: Da eine Instanz von D alle Felder von B enthält und alle Methoden von B »versteht«, kann sie jederzeit anstelle einer Instanz von B stehen. Die Umkehrung gilt natürlich nicht, da die Superklasse B von zusätzlichen Feldern und Methoden in D nichts »weiß«.
Member-Vererbung und Widening-Conversion haben weit reichende Folgerungen, die im Weiteren detaillierter besprochen werden.
5.2 Overriding vs. Overloading
Die Neuimplementierung einer geerbten Methode nennt man Overriding (Überschreiben).
Overriding
in Subklassen
|
Overriding gibt es nur in Subklassen. |
|
Overriding verlangt nicht nur dieselbe Signatur (siehe 3.4.3), sondern auch denselben Rückgabetyp wie die Methode der Superklasse. |
Abbildung 5.3 Overriding einer Methode f()
Overriding stellt immer die passende Methode sicher
Overriding-Regel: Überschreibt die Klasse D eine Instanz-Methode von B, so wird zu einer Instanz von D nur die Methode von D und nicht die von B aufgerufen.
Diese Overriding-Regel gilt uneingeschränkt! Auch über eine Referenz vom Typ B oder mittels Casts nach B ist es nicht möglich, zu einer Instanz von D eine Instanz-Methode von B aufzurufen.
Beispiel (zu Abb. 5.3)
class B { void f(int i) {System.out.println(i);}
}
class D extends B {void f(int i) {System.out.println(i*i);}}
class OverridingTest {
static void show (B b) { b.f(2); }
static void test () {
D d= new D();
B b= d; // Instanz von D über b referenzieren
show (b); // trotz Argument b :: 4
((B)d).f(2); // trotz Cast nach B :: 4
}
}
Der Aufruf von OverridingTest.test(); erzeugt als Ergebnis immer 4.
Overriding ist
ein sinnvoller Mechanismus
Erklärung: Die Overriding-Regel ist sinnvoll. Eine Methode in einer Subklasse wird nur dann überschrieben, wenn das Verhalten der Methode der Superklasse nicht mehr gültig ist. Würde also die Overriding-Regel nicht gelten, wäre es mittels Casts oder Referenz möglich, zu Objekten ungültige Methoden aufzurufen.
Overloading
Overloading:
Statische Polymorphie
Im Gegensatz zu Overriding bedeutet Overloading (Überladen) in einer Klasse die Wiederverwendung des Namens für Methoden mit verschiedener Signatur.
Overriding:
Dynamische
Polymorphie
Der Compiler kann bereits beim Übersetzen anhand der unterschiedlichen Signaturen die einzelnen Methoden mit gleichem Namen unterscheiden.
|
Overriding ist somit unbedingt von Overloading zu unterscheiden. |
Die Signatur-Regel in Abschnitt 3.4.3 wird für Klassen-Hierarchien erweitert.
Signatur in Hierarchien
Erweiterte Signatur-Regel: In einer Klassen-Hierarchie können keine zwei Methoden mit derselben Signatur vorkommen, es sei denn, eine Methode in der Subklasse überschreibt eine andere in einer Superklasse.
Beispiel
class B {
String s= "B";
void f(int i) { System.out.println(i); }
void g() {}
}
class D extends B {
// int g() {} ß Compiler-Fehler
¨
void f(byte b) { System.out.println(b); } ¦
void f(int i) { System.out.println(s+" "+i); }
}
public class Test {
public static void main(String[] args) {
D d= new D();
B b= d; ¬
b.f((byte)1);
:: B 1 Ø
d.f((byte)1); ::
1
d.f(1); ::
B 1
}
}
Erklärung:
Zu ¨: Nach der Signatur-Regel hat die Methode int g() in D dieselbe Signatur wie void g() in B, aber überschreibt sie nicht. Dies erzeugt einen Compiler-Fehler.
Zu ¦: Beide Methoden f() in D sind erlaubt. Die erste hat unterschiedliche Signatur, die zweite überschreibt f() in B.
Zu Æ: Aufgrund von Widening Conversion kann eine Instanz von D überall da stehen, wo ein Typ B erwartet wird.
Zu Ø: Eine Referenz b vom Typ B kennt nur eine Methode f(int i), ruft aber nach der Overriding-Regel die Methode f(int i) von D auf.
Die Referenz d vom Typ D kennt zwei verschiedene Methoden mit Namen f und ruft deshalb die zum Argument passende auf.
5.3 Superklasse Object
Object, die
ultimative Superklasse
Jede Klasse, die man deklariert, hat eine Superklasse, auch wenn keine Superklasse mittels extends angegeben wird.
|
Alle Klassen haben als Superklasse die Klasse Object, die selbst keine Superklasse besitzt. |
Damit erbt jede Klasse automatisch alle elf Instanz-Methoden der Klasse Object. Hierzu gehören auch die beiden schon besprochenen wichtigen Methoden:
public boolean equals(Object
obj)
public String toString()
Um deren Verhalten an die jeweilige Klasse anzupassen, müssen diese Methoden überschrieben werden.
Verwechslung von Overloading und Overriding
class Complex {
double re,im;
//...
public boolean equals(Complex c) {
return re==c.re && im==c.im;
}
//...
}
Obwohl die Methode equals() gut aussieht, hat sie die Methode aus Object nicht überschrieben, sondern nur überladen. Dies kann – je nach Code – zu subtilen semantischen Fehlern führen.
5.4 Vererbung und Modifikatoren
Nachfolgend werden die Modifikatoren in Hinblick auf die Vererbung besprochen, d.h., welchen Einfluss sie auf die Vererbung haben bzw. welche Semantik damit verbunden ist.
5.4.1 abstract
In Java hat der Modifikator abstract dieselbe Bedeutung wie in der UML (siehe 4.2.6) und kann auf Klassen und Instanz-Methoden angewendet werden.
abstract: keine Instanzen möglich
Von einer Klasse, die abstrakt erklärt wird, können keine Instanzen angelegt werden. Wird dagegen eine Methode abstrakt erklärt, hat sie keinen Methoden-Rumpf, d.h. keine Implementierung.
Eine Klasse muss abstrakt erklärt werden, wenn sie unvollständig ist, genauer gesagt, wenn sie
abstract:
notwendig bei Unvollständigkeit
|
eine abstrakte Methode enthält oder erbt, die sie nicht implementiert. |
|
im Klassenkopf ein Interface enthält, deren Methoden sie aber nicht vollständig implementiert. |
Ansonsten kann man eine Klasse abstrakt erklären, auch wenn sie keine abstrakten Methoden enthält.
Dies macht dann Sinn, wenn die Klasse zwar eine Superkasse ist, aber noch so unvollständig ist, dass eigene Instanzen ziemlich nutzlos sind.
5.4.2 final
final: keine Subklassen möglich
Zu einer final deklarierten Klasse2 kann es keine Subklassen mehr geben. Eine final deklarierte Methode kann in einer Subklasse nicht mehr überschrieben werden.3
Dieser Modifikator wird dafür eingesetzt, sicherzustellen, dass aufgrund einer Vererbung nichts mehr geändert werden kann. Deshalb ist final für Klassen bzw. Methoden mit Vorsicht einzusetzen, da es auch Erweiterungen ausschließt. Andererseits dient es zur Geschwindigkeits-Optimierung (siehe 5.8.1).
Die Klassen Math, System und die Wrapper der primitiven Typen sind häufig benutzte final deklarierte Klassen im Package java.lang.
5.4.3 Zugriffs-Modifikatoren (Access-Modifier)
Zugriffs-
Modifikatoren: für Package-Ebene und Member
Die drei Modifikatoren public, protected, private sowie der »ohne Namen« regeln den Zugriff auf Klassen, Interfaces und Member.
Es ist zwischen dem Zugriff auf Klassen und Interfaces, die auf Package-Ebene deklariert sind, und dem Zugriff auf ihre Member – Felder und Methoden – zu unterscheiden.
Zugriff auf Klassen/Interfaces (auf Package-Ebene)
Für Klassen und Interfaces auf Package-Ebene ist entweder kein Modifikator oder nur public erlaubt.
Auf Package-Ebene: nur public oder default
Ohne Modifikator können Klassen und Interfaces nur in dem Package verwendet werden, in dem sie deklariert sind. Dies nennt man (default) Package-Zugriff oder auch friendly Zugriff.
Wird eine Klasse/Interface public deklariert, kann sie in jedem Package verwendet werden.
Abbildung 5.4 Inter-Package-Zugriff (UML-Darstellung)
Zugriff auf Interface-Member
Interface-Member: immer public Methoden: abstract Felder: static final
Interface-Methoden sind implizit public abstract und können auch nur explizit public und/oder abstract deklariert werden (was somit ziemlich unnötig ist).
Interface-Felder sind implizit public static final und können auch nur explizit public und/oder static und/oder final deklariert werden (was somit wiederum unnötig ist).
Damit bieten alle Member eines Interfaces überall da Zugriff, wo das Interface selbst Zugriff bietet (d.h. im Package oder auch außerhalb).4
Zugriff auf Klassen-Member
Klassen-Member:
drei Modifikatoren – vier Zugriffsarten
Der Zugriff auf Klassen-Member kann mit Hilfe der Zugriffs-Modifikatoren (Access-Modifiers) kontrolliert werden:
|
public: Die Member einer Klasse bieten überall da Zugriff, wo auch die Klasse Zugriff bietet (im Package oder auch außerhalb). |
|
protected: Die Member einer Klasse bieten im Package Zugriff und auch innerhalb einer Subklasse außerhalb des Packages, sofern die Klasse public erklärt ist. |
|
kein Modifikator (default/friendly/Package-Zugriff): Die Member einer Klasse bieten für alle Klassen desselben Packages Zugriff. |
|
private: Die Member bieten nur innerhalb der eigenen Klasse Zugriff. |
Damit werden also vier verschiedene Stufen der Zugriffsrestriktion auf Member definiert.
Überschreiben der Zugriffs-Modifikatoren
Die Modifikatoren können nach der Stärke ihrer Zugriffsrestriktion geordnet werden (siehe Abb. 5.5).
Ordnung der Zugriffsarten
Diese Ordnung ist dann wichtig, wenn man in einer Subklasse für eine überschriebene Methode die Zugriffsart abweichend von derjenigen in der Superklasse setzen will.
Abbildung 5.5 Erlaubte Richtung der Zugriffsänderung bei Overriding
Bei Overriding gilt folgende Regel für Zugriffs-Modifikatoren.
Änderung der Zugriffsart in Subklassen
Access-Widening: Wird eine Methode überschrieben, kann der Zugriffs-Modifikator der Superklasse in der Subklasse nur mit einem Zugriffs-Modifikator überschrieben werden, wenn dieser gleich oder weniger restriktiv ist (d.h. in Pfeilrichtung bei Abb. 5.5).
Diese Regel ist für Widening-Conversion notwendig, d.h. um zu verhindern, dass Methoden durch Überschreiben plötzlich keinen Zugriff mehr bieten.
Package-Grenzen-Problematik bei protected:
Werden Subklassen über Package-Grenzen hinweg deklariert, hat der Modifikator protected einen kleinen logischen »Twist«.
protected: Vorsicht bei unterschiedlichen Packages
Der Modifikator protected lässt den Zugriff nur auf direkt geerbte Member desselben Objekts zu, nicht jedoch auf protected Member in anderen Objekten derselben Superklasse.
|
Beispiel
Im linken Kasten ist eine Klasse Base im Package p1 erklärt. Im rechten Kasten ist eine Klasse Derived im Package p2 von Base abgeleitet. Die Klasse Derived kann damit zwar auf das instanzeigene protected int i zugreifen, nicht jedoch auf ein i einer anderen Instanz.
package p1;
public class Base {
protected int i= 2;
}
|
package p2;
class Derived extends p1.Base {
void foo(p1.Base o) {
System.out.println(i+ o.i); ¨
}
}
|
Erklärung: Die Zeile ¨ erzeugt einen Compiler-Fehler, verursacht durch o.i. Der Zugriff auf das eigene geerbte protected Feld i ist erlaubt, nicht jedoch auf das Feld i eines anderen Objekts derselben Klasse.
5.4.4 Unverträglichkeiten von Modifikatoren
Klassen:
abstract vs. final
Bei Klassen kann der Modifikator final (keine Subklasse erlaubt!) nicht zusammen mit abstract (Subklasse notwendig!) verwendet werden.
Member:
abstract vs. final, static, private
Bei Methoden können die Modifikatoren final, static und private nicht zusammen mit abstract verwendet werden. Abstrakte Methoden verlangen ein Overriding, wogegen final und private dies gerade ausschließen.
Die Modifikatoren abstract und static schließen sich dadurch aus, dass statische Methoden nicht im Sinne von Instanz-Methoden überschrieben5 , sondern nur verdeckt werden können (siehe hierzu 5.8.1).
5.5 Konstruktoren
Konstruktoren: eine eigene Methoden-Familie
Bei der Anlage einer Instanz wird direkt nach Allokation des Speichers durch die JVM ein Konstruktor (constructor) aufgerufen, der die Initialisierung des neu erschaffenen Objekts übernimmt.
Konstruktoren bilden syntaktisch eine eigene Gruppe von Methoden und unterliegen damit auch anderen Regeln als die normalen Instanz-Methoden (siehe auch 3.4.3 und 5.5.3).
Klassen ohne Konstruktoren sind unmöglich
Es gilt folgende einfache Regel:
|
Es gibt keine Klasse ohne Konstruktor. In jeder Klasse, auch in einer abstrakten, existiert zumindest ein Konstruktor. |
5.5.1 Default-Konstruktor
Default-
Konstruktor: vom System geschrieben
Der genannten Regel wird dadurch entsprochen, dass in einer Klasse, in der kein Konstruktor explizit deklariert ist, der Compiler automatisch einen so genannten Default-Konstruktor (default constructor) anlegt.
Ein Default-Konstruktor hat keine Parameter und hat denselben Zugriffs-Modifikator wie seine Klasse.
Somit können ohne Anlage eines Konstruktors von jeder Klasse C Instanzen mit new C() angelegt werden.
5.5.2 Deklaration und Initialisierungs-Regeln
Deklaration eines Konstruktors
Legt man explizit Konstruktoren an, müssen sie immer den Namen der Klasse tragen und dürfen keinen Typ als Rückgabe definieren. Damit hat ein Konstruktor die folgende Deklaration:
[accessModifier] ClassName (parameterList) [throwsList] {...}
Beispiele
No-Arg-Konstruktor: ein
Konstruktor ohne Argumente
class Point {
int x, y;
Point() {} // analog zum Default-Konstruktor
Point(int x, int y) { this.x= x; this.y= y; }
}
abstract class Figure { // abstrakt!
Point base;
Figure (int x,int y) {
base= new Point(x,y); // legt einen Basispunkt (x,y) an
}
}
Folgende Konstruktoren-Regeln sind zu beachten:
Zusammenstellung aller wichtigen Regeln zum Konstruktor
1. |
Wird in einer Klasse (explizit) ein Konstruktor mit Parameter deklariert, legt der Compiler keinen Default-Konstruktor mehr an. |
2. |
Ruft ein Konstruktor einen anderen derselben Klasse auf, so erfolgt dies mit der Anweisung |
this(argumentList);
wobei argumentList zur parameterList des aufgerufenen Konstruktors passen muss.
3. |
Ruft ein Konstruktor einen anderen der direkt übergeordneten Superklasse auf, so erfolgt dies mit der Anweisung |
super(argumentList);
wobei argumentList zur parameterList des aufgerufenen Kon-struktors passen muss.
4. |
Sofern verwendet, muss this(); oder super(); die erste Anweisung in einem Konstruktor sein, d.h., sie können nicht beide gleichzeitig verwendet werden. |
5. |
Verwendet man weder this() noch super(), fügt der Compiler als erste Anweisung super(); ein, d.h., es wird zuerst der No-Arg-Konstruktor der direkt übergeordneten Superklasse aufgerufen. |
6. |
Für die Felder, die nicht im Konstruktor initialisiert werden, wird vom Compiler automatisch Code generiert. Dieser Code wird nach der Ausführung des Superklassen-Konstruktors und vor dem Code des eigenen Konstruktors ausgeführt. Dabei wird die Reihenfolge der Variablen beachtet. |
7. |
Konstruktoren werden nicht vererbt, jede Subklasse muss ihre eigenen Konstruktoren anlegen. |
8. |
Blank final Instanz-Variablen müssen im Konstruktor oder Instanz-Initialisierer (siehe 5.7.1) einen Wert zugewiesen bekommen. |
Reihenfolge der Konstruktoren
bei Klassen- Hierarchie
Eine Instanz enthält alle internen Instanzen der Superklassen, die bei einer mehrstufigen Hierarchie wie Schalen einer Zwiebel ineinander liegen.
Vom Compiler wird sichergestellt, dass zuerst immer die Felder der Superklasse initialisiert werden, und zwar (von innen nach außen) in der Reihenfolge der Klassen-Hierarchie. Dies gewährleistet die fünfte Regel (siehe Abb. 5.6).
Beispiel
Abfolge der Konstruktoren-Aufrufe zu den Klassen in Abb. 5.6.
Zwiebelschalen-Modell mit
Konstruktoren-Reihenfolge
Abbildung 5.6 Reihenfolge der Konstruktoren-Aufrufe
class B {
protected int b= 3;
B() { System.out.print("B b:"+b); } // siehe 6. Regel ¨
}
class C extends B {
protected int c;
C(int i) {System.out.print("C "); c= b+i; } // siehe 5. Regel ¦
}
class D extends C{
protected int d;
D(int i,int j) { super(j); // siehe 3. & 4. Regel
System.out.print("D "); d= c+i;
System.out.println("b:"+b+" c:"+c+" d:"+d);
}
}
public class Test {
public static void main(String[] args) {
new D(1,2); // :: B b:3 C D c:5 d:6
}
}
Erklärung: In ¨ ist b bereits mit 3 initialisiert, in ¦ steht aufgrund der fünften Regel ein implizites super(); als erste Anweisung. Jede Subklasse kann im Konstruktor bereits auf die initialisierten Werte ihrer Superklasse zugreifen.
5.5.3 Signatur-Regel für Konstruktoren
Unterscheidung der Konstruktoren anhand der Signatur
Das Overloading der Konstruktoren unterliegt der einfachen Signatur-Regel, d.h., Konstruktoren müssen untereinander anhand der Sequenz ihrer Parametertypen zu unterscheiden sein (siehe auch 5.6.2, Fall 2).
5.5.4 Zusammenarbeit von Konstruktoren
super() erlaubt Zusammenarbeit der Konstruktoren in der
Hierarchie
Konstruktoren können über Klassengrenzen hinweg zusammenarbeiten.
Beispiel
class Point {
// wie in 5.5.2
}
class Figure {
Point base;
// Figure() {}
¨
Figure (int x,int y) {
// Figure enthält einen Punkt, also nicht
// super(x,y) sondern new Point()
base= new Point(x,y);
}
}
Vorsicht bei Default-
Konstruktoren
class Circle extends Figure {
int radius; // Compiler-Fehler: 2. & 5. Regel
// Circle(int x,int y,int r) { super(x,y); radius=r; } ¦
}
Erklärung: Nach der zweiten Regel gibt es in Figure keinen Default-Konstruktor. In Circle gibt es nur einen Default-Konstruktor, der nach der fünften Regel als erste Anweisung implizit ein super() enthält. Dies führt zur Compiler-Meldung »constructor Figure() not found«.
Der Fehler kann durch Entfernen der Kommentar-Symbole in Zeile ¨ und/oder ¦ behoben werden:
Fügt man nur den Konstruktor in ¨ ein (und nicht ¦), stellt man fest, dass er nicht sehr gut passt:
System.out.println(new Circle().base.x); // Laufzeitfehler!
Figure() setzt nämlich keinen Basispunkt wie Figure(int x,int y), sondern überlässt die Initialisierung von base dem System:
System.out.println(new Circle().base); // ::
null
5.6 Pros und Kons der Initialisierung
5.6.1 Design-Pros
Die Initialisierung bei der Deklaration der Variablen6 steht im Gegensatz zur Initialisierung erst im Konstruktor. Die Frage ist dann, was zu bevorzugen ist. Die Antwort lautet »Klarheit«:
Direkte Initialisierung vs. Konstruktoren
Initialisierungs-Prinzip: Sofern nicht im Konstruktor ohnehin eine (Re-) Initialisierung stattfindet, sollten die Instanz-Felder direkt bei der Deklaration initialisiert werden.
|
Andererseits – werden die Felder in einem Konstruktor wieder auf andere Werte, z.B. die der Argumente gesetzt – verkehrt sich die Klarheit ins Gegenteil:
class BadIdea {
String s=""; // sinnlos!
BadIdea(String s) { this.s= s; }
}
Innerhalb einer Klasse arbeiten Konstruktoren auf der Basis von Constructor-Chaining zusammen:
Constructor-
Chaining: Vermeidung von Code-Redundanz
Constructor-Chaining: Um Code-Replikationen zu vermeiden, sollten einfachere Konstruktoren komplexere aufrufen, wobei die einfacheren vor den komplexeren Konstruktoren deklariert werden.
|
Beispiel
class IntMatrix {
int[][] m;
IntMatrix(int dim) { this(dim,dim);
}
IntMatrix(int rows, int cols) { this(rows,cols,0); }
IntMatrix(int rows, int cols, int val) {
m= new int[rows][cols];
if (val!= 0) setVal(val); // Aufruf einer Methode
}
void setVal(int val) {
for (int i= 0; i<m.length; i++)
for (int j=0; j<m[i].length; j++) m[i][j]= val;
}
}
Die Klasse IntMatrix deklariert drei Konstruktoren, geordnet nach Detaillierung. Es können quadratische und nicht quadratische Matrizen mit bzw. ohne Wert angelegt werden:
IntMatrix imat1= new IntMatrix(); //
C-Fehler
IntMatrix imat2= new IntMatrix(3);
IntMatrix imat3= new IntMatrix(3,4);
IntMatrix imat4= new IntMatrix(3,4,-1);
Der letzte Konstruktor demonstriert, dass in Konstruktoren durchaus andere Instanz-Methoden aufgerufen werden können.
Gemeinsame Initialisierungs-Methoden
Der Aufruf einer Instanz-Methode eignet sich auch vorzüglich, um das Problem zu lösen, dass man nur super() oder this() verwenden darf, aber nicht beides, obwohl dies manchmal sinnvoll wäre.
Das Codierungs-Prinzip ist einfach:
Umgehung der Beschränkung für super() und this()
|
Man verwendet super() und ruft anschließend eine Initialisierungs-Methode auf, in die man den in den Konstruktoren gemeinsam genutzten Code ausgelagert hat. |
Denn eine Initialisierungs-Methode muss man nicht unbedingt als erste Anweisung ausführen.
5.6.2 Design-Kons
Das Folgende ist zwar zulässig, aber zu vermeiden, da seine Wirkung nicht klar ist.
Vorsicht:
abstrakte Methoden im Konstruktor
Ein Konstruktor ruft eine abstrakte Methode auf, die Felder der Subklasse ändert.
Beispiel
abstract class Money {
Money() { set(1.0); } // Wirkung unklar!
abstract void set(double money);
}
class Euro extends Money {
private double euro /*= 0.0*/ ; // siehe Erklärung
void set(double money) { euro= money; }
double get() { return euro; }
}
Der Fall ist dubios, da primitive Typen wie euro eigentlich automatisch auf 0.0 initialisiert werden. Doch in diesem Fall sind die beiden Deklarationen unterschiedlich:
private double euro; ¨
private double euro= 0.0; ¦
Die Ausgabe ergibt:
System.out.println(new Euro().get()); // ¨
:: 1.0
// ¦ :: 0.0
Der Money-Konstruktor wird immer vor dem Euro-Konstruktor aufgerufen. Der Default-Konstrukor von Euro – explizit geschrieben – sieht wie folgt aus:
Euro() { super(); }
Bei ¨ wird der Wert also auf 1.0 gesetzt.
Bei ¦ fügt, gemäß der sechsten Regel in Abschnitt 5.5.2, der Compiler nach super(); allerdings noch die Anweisung euro= 0.0; ein.
Konstruktoren vs. normale Methoden
Der Compiler unterscheidet zwischen Konstruktoren und normalen Instanz-Methoden.
Falle:
void-Konstruktoren
|
Es können Instanz-Methoden deklariert werden, die den Namen der Klasse haben und sogar dieselbe Signatur wie ein Konstruktor. |
Das ist zulässig7 , aber unsinnig, und geschieht meistens dadurch, dass man aus Versehen void vor einen Konstruktor geschrieben hat.
Beispiel
class Nonsense {
Nonsense() { System.out.println("Konstruktor-Nonsense"); }
void Nonsense() { System.out.println("Methode-Nonsense"); }
}
Instanz-Methode und Konstruktor haben dieselbe Signatur und können nun wie folgt aufgerufen werden:
Nonsense u= new Nonsense(); // :: Konstruktor-Nonsense
u.Nonsense(); // :: Methode-Nonsense
5.7 Initialisierer
Konstruktoren vs. anonyme Klassen
Da Konstruktoren immer den Namen der Klasse tragen müssen, entstand nach Einführung der anonymen Klassen8 in Java 1.1 das Problem der Namensfindung für einen expliziten Default-Konstruktor.
Des Weiteren sind Konstruktoren ungeeignet, um statische Variablen zu initialisieren (siehe 5.7.2).
5.7.1 Instanz-Initialisierer
Die Lösung des ersten Problems war ein Konstruktor ohne Namen für eine anonyme Klasse.
Alternative:
Instanz- Initialisierer
Ein Instanz-Initialisierer (instance initializer) besteht nur aus geschweiften Klammern, dem Initialisierungsblock:
class C {
{
// Initialisierungs-Block
}
}
Instanz-
Initialisierer: Rolle eines Default- Konstruktors
Ein Instanz-Initialisierer hat keine Parameter, entspricht also dem Default-Konstruktor.
In einer Klasse können sogar mehrere Instanz-Initialisierer vorkommen. Sinnvoll und notwendig sind sie aber nur für anonyme Klassen und auch hier sollte man sich auf einen beschränken.
Es gelten folgende Regeln:
Abfolge bei:
Direkt-Initialisierung Instanz- Initialisierer Konstruktoren
1. |
Bei der Anlage einer Instanz wird der Code im Instanz-Initialisierer vor dem Code eines Konstruktors ausgeführt. |
2. |
Die Variablen, die im Instanz-Initialisierer verwendet werden, müssen vor diesem deklariert sein. |
3. |
Die Initialisierung von Variablen direkt bei der Deklaration und in Instanz-Initialisierern erfolgt in der Reihenfolge der Angaben. |
Beispiel
Um die Regeln abzudecken, ist die folgende Klasse IntVector konstruiert und verstößt gegen gutes Design.
class IntVector {
int[] v;
{
System.out.println("Initialisierer");
//standardVal= 1; ¨
v= new int[standardDim]; // ok: static
}
IntVector () {
System.out.println("default");
java.util.Arrays.fill(v,standardVal);
}
static int standardDim= 5; // siehe 5.7.2
int standardVal= 1;
}
Erklärung: Nach der zweiten Regel kann standardVal im Initialisierer nicht verwendet werden. Ein Entfernen des Kommentars in ¨ führt zu einem Compiler-Fehler.
Wird eine Instanz von IntVector angelegt, z.B. durch
new IntVector(); //
:: Initialisierer
default
wird zuerst der Code des Initialisierers ausgeführt, dann standardVal auf 1 gesetzt und erst dann der Code im No-Arg-Konstruktor ausgeführt.
5.7.2 Statischer Initialisierer
Statischer Initialisierer:
Ausführung beim Laden der Klasse
Statische Variablen existieren unabhängig von Instanzen und werden – bevor irgendwelche Instanzen existieren – bereits beim Laden der Klasse angelegt und initialisiert.9 Deshalb ist auch die Initialisierung von statischen Feldern in Konstruktoren unsinnig.
Sofern möglich, sollte die Initialisierung der statischen Felder sofort bei der Deklaration erfolgen. Bei einer komplexen Initialisierung ist dies nicht möglich und wird deshalb in einem statischen Initialisierer (static initializer) durchgeführt, der aus einem Block mit vorangestelltem static besteht:
class {
static { // statischer Initialisierungs-Block }
}
Ein statischer Initialisierer hat keine Parameter und wird nur einmal ausgeführt, direkt nachdem die Klasse geladen wurde. Eine Klasse kann mehrere statische Initialisierer haben, einer reicht allerdings aus.
Es gelten folgende Regeln:
Nur statische Felder Abfolge
der statischen Initialisierung
1. |
Ein statischer Initialisierer hat – wie alle statischen Methoden – keinen Zugriff auf Instanz-Variablen. |
2. |
Analog zur zweiten Regel in 5.7.1. |
3. |
Analog zur dritten Regel in 5.7.1. |
4. |
Blank final Klassen-Variablen müssen im statischen Initialisierer einen Wert zugewiesen bekommen. |
Beispiel
Beim Laden der Klasse SinusTable berechnete Tabelle von Sinuswerten:
final class SinusTable {
final static int MAXDEGREE= 90;
final static double sin[]; // blank final
static {
sin= new double[MAXDEGREE+1];
for (int i=1; i<=MAXDEGREE; i++)
sin[i]= Math.sin(i*Math.PI/180.);
}
private SinusTable(){}; // keine Instanzen, keine Subklasse
}
Erklärung: Die Klasse SinusTable bedient sich genau eines private-Konstruktors.
Damit sind weder Instanzen erlaubt noch kann man eine Subklasse von SinusTable anlegen, da deren Konstruktor den SinusTable-Konstruktor aufrufen muss (was aber nicht geht!).10
Die Tabelleneinträge können dann folgendermaßen abgefragt werden:
System.out.println(SinusTable.sin[0]);
// :: 0.0
System.out.println(SinusTable.sin[30]);
// :: 0.49999999999999994
5.8 Overriding vs. Shadowing
Bei Vererbung verhalten sich Instanz-Methoden im Vergleich zu Feldern und statischen Methoden unterschiedlich.
5.8.1 Statischer vs. virtueller Aufruf von Methoden
Virtueller Methodenaufruf: JVM ermittelt korrekte Methode
Wie bereits besprochen, wird zur Laufzeit von der JVM – unabhängig vom Typ der Referenz – die zum Objekt gehörige Instanz-Methode ermittelt und ausgeführt. Diese Art nennt man
|
virtuellen Methodenaufruf oder |
Synonyme: dynamisch, late oder dynamic binding
|
dynamischer Methodenaufruf oder |
|
late bzw. dynamic binding. |
Diese Bezeichnungen stehen dafür, dass der Compiler beim Übersetzen einer Instanz-Methode in der Regel nicht weiß, welches Objekt von einer Variablen nun referenziert wird.
Statischer
Methodenaufruf: Compiler ermittelt die korrekte Methode
Dies steht im Gegensatz zum statischen Aufruf von Methoden bzw. early binding, wo bereits bei der Kompilierung feststeht, welche Methode ausgeführt werden soll, die dann auch bereits im Code eingebunden werden kann.
Statisch aufgerufene Methoden
Der dynamische Methodenaufruf zur Laufzeit kostet Zeit und wird nur dann benutzt, wenn auch ein Overriding vorkommen kann. In einigen Fällen ist Overriding jedoch verboten oder ausgeschlossen:
static/final/private-
Methoden erlauben statische Aufrufe
|
Alle static, private oder final deklarierten Methoden bzw. alle Methoden einer final deklarierten Klasse benötigen keinen dynamischen Methodenaufruf. |
Für diese Methoden steht nämlich schon zur Übersetzungszeit fest, dass sie nicht durch Methoden von Subklassen überschrieben werden können.
Interessant sind statische Methoden:
|
Statische Methoden einer Superklasse werden in einer Subklasse durch eine identische statische Methode nicht überschrieben, sondern nur verdeckt (shadowed).11 |
Über den Klassennamen oder per Cast kann man – im Gegensatz zu Instanz-Methoden – alle statischen Methoden aller Klassen der Hierarchie jederzeit aufrufen.
Statische Methode:
nur über Klassennamen aufrufen
Deshalb sollte man ein einfaches Idiom beachten:
|
Statische Methoden sollte man nicht wie Instanz-Methoden über eine Referenz, sondern immer über den Klassennamen aufrufen. |
|
Verstößt man gegen dieses Idiom, wird – im Gegensatz zu Instanz-Methoden – anhand der Referenz und nicht des Objekts die zugehörige Klasse ermittelt und deren statische Methode aufgerufen.
Dies führt zu schwer durchschaubarem Code, da statische Methoden wie Instanz-Methoden aussehen.
Statische Methoden:
Aufruf über Instanzen werden mit Unklarheit bestraft
Beispiel
Der nachfolgende Code zeigt die sehr verwirrende Art, static deklarierte Methoden wie Instanz-Methoden aufzurufen:
class BC {
static void sf() {System.out.println("BC.sf()"); }
void dynf() {System.out.println("BC.dynf()"); }
}
class DC extends BC {
static void sf(){System.out.println("DC.sf()"); } // shadowing
void dynf() {System.out.println("DC.dynf()");} // overriding
}
public class Test {
static void test(BC bc) {
bc.sf(); // besser: BC.sf(); :: BC.sf()
((DC)bc).sf(); // besser: DC.sf(); :: DC.sf()
bc.dynf(); // dynamic Lookup! :: DC.dynf()
}
public static void main(String[] args) {
DC dc= new DC();
BC bc= dc;
dc.sf(); // besser: DC.sf(); :: DC.sf()
bc.sf(); // besser: BC.sf(); :: BC.sf()
bc.dynf(); // dynamic Lookup! :: DC.dynf()
test(bc); // siehe oben
}
}
5.8.2 Shadowing von Feldern und super
Wie statische Methoden werden auch Felder von Superklassen in Subklassen nicht überschrieben, sondern nur verdeckt.
Verdeckte Felder in Superklassen: Einsatz von Cast oder super
Sollte man in einer Subklasse einem Feld denselben Namen geben wie einem Feld in der Superklasse, erhält man zwei Felder mit gleichem Namen, auf die man mittels Casts zugreifen kann, sofern dies der Access-Modifier zulässt.
|
Innerhalb einer Subklasse kann mit Hilfe von super.feld das verdeckte Feld der Superklasse aufgerufen werden, die in der Hierarchie am nächsten liegt. |
Beispiel
Einmal wird innerhalb der Subklasse D, einmal von außen über Test auf verdeckte Felder der Superklassen B1 und B2 zugegriffen.
class B1 {
String s= "B1";
int i=1;
}
class B2 extends B1 {
String s= "B2";
}
(Superklasse) this
hilft
class D extends B2 {
String s= "D";
int i=2;
void f() {
System.out.println(super.s);
// :: B2
System.out.println(super.i);
// :: 1
System.out.println(((B2)this).s);
// :: B2
System.out.println(((B1)this).s);
// :: B1
//System.out.println(super.super.s); Fehler: nicht so!
}
}
public class Test {
public static void main(String[] args) {
D d= new D();
System.out.println(((B2)d).s+" "+((B1)d).s); // :: B2 B1
d.f();
}
}
5.8.3 Aufruf überschriebener Methoden mittels super
super: einzige Zugriffsmöglichkeit auf überschriebene Methoden
Innerhalb einer Subklasse kann man nicht nur auf Felder, sondern auch auf überschriebene Methoden der Superklasse zugreifen. Allerdings darf man dazu nur das Schlüsselwort super und keinen Cast verwenden.
super vs. super()
Außer dem Namen hat super mit dem Aufruf des Superklassen-Konstruktors super() nichts gemeinsam (siehe 5.5.2). Innerhalb einer Instanz-Methode ruft super.method() eine überschriebene Methode auf und kann auch überall stehen.
Auch für Methoden gilt – wie bei den Feldern – folgende Einschränkung:
super liefert nur die nächste
überschriebene Methode
|
Innerhalb einer Subklasse kann mit Hilfe von super.method() die überschriebene Methode der Superklasse aufgerufen werden, die in der Hierarchie am nächsten liegt. |
Somit ist es mit super.method() nicht möglich, eine Methode einer Basisklasse auszuwählen, die in der Klassenhierarchie mehrfach überschriebene Methode wurde.
Die Suche einer Methode method() mittels super.method() beginnt immer bei der direkten Superklasse und hört bei der ersten passenden Methode einer Superklasse auf.
class B1 {
void f() {System.out.println("B1.f()"); };
void g() {System.out.println("B1.g()"); };
}
class B2 extends B1 {
void f() {
System.out.println("B2.f()");
super.f(); // muss nicht erste Anweisung sein
}
}
class D extends B2 {
void g() {
super.f(); // :: B2.f()
// :: B1.f()
super.g(); // :: B1.g()
}
}
Die Methode B1.f() kann von D nicht aufgerufen werden.
5.9 Speicherverwaltung
Das Thema »Speicherverwaltung« hätte aufgrund der recht häufigen Fehlbeurteilung eigentlich ein eigenes Kapitel verdient.
Destruktoren
gibt es NICHT!
In Java verläuft zwar die Objekt-Anlage mittels new und Konstruktoren analog zu C++, aber in Java gibt es kein Äquivalent zu Destruktoren bzw. delete(), so wie man es in C++ kennen und schätzen gelernt hat.12
Garbage-Collection: die Befreiung von der Speicherbereinigung via Destruktor
Der Grund für eine automatische Speicherplatzfreigabe – und hierfür steht der Begriff Garbage Collection in Java – ist es, den (Anwendungs-) Programmierer von der sehr fehlerträchtigen Aufgabe der manuellen Speicherfreigabe zu entlasten.
Dadurch wird natürlich auch so manches Idiom überflüssig, was man in C++ zur Speicherbereinigung gelernt hat.
Nun lassen sich C++-Konvertiten nicht so leicht überzeugen, dass dieses vormals mühselige und schwierige Geschäft nun reibungslos automatisch geschieht.
Ist finalize() ein Destruktor?
Deshalb wird als Anwort auf die Frage, wie denn z.B. vor der automatischen Objekt-Entsorgung die Ressourcen-Freigabe vorzunehmen ist, die Methode finalize() als Destruktor in »Java für C++-Umsteiger« verkauft.13
Wider den Mythos
Destruktor
Das Hauptanliegen dieses Abschnitts ist es, den Mythos der Methode finalize() als Destruktor bzw. De-Initialisierer zu zerstören und gangbare Alternativen aufzuzeigen.
|
5.9.1 Garbage Collection
Garbage Collection (GC) ist ein fester Bestandteil der jeweiligen JVM und kann vom Programmierer nicht direkt ausgetauscht bzw. beeinflusst werden.
Erreichbarkeit: Objekte werden noch gebraucht
GC ist nur vom Konzept her festgelegt und basiert auf dem Prinzip der Erreichbarkeit:
|
Ein Objekt heißt erreichbar (reachable), wenn es noch irgendeine (gültige) Referenz im Programm gibt, die auf dieses Objekt verweist. |
Unerreichbarkeit: Kriterium für
Entsorgung
Kann ein Objekt im Programm nicht mehr über eine lokale Variable, ein Feld oder Array-Element angesprochen werden, ist es unerreichbar und kann aus dem Speicher entfernt werden.
Ablauf der
automatischen Speicherbereinigung
In der JVM läuft der GC als ein nebenläufiger Prozess (Thread) mit niedriger Priorität, der die folgenden Aufgaben periodisch nacheinander erledigt:
1. |
Der GC untersucht alle Objekte im Speicher darauf, ob sie noch erreichbar sind. |
2. |
Bevor ein nicht erreichbares Objekt von dem GC aus dem Speicher entfernt wird, ruft der GC die von Object geerbte Methode finalize() genau einmal auf. |
3. |
Der Speicher, den das Objekt besetzt, wird freigegeben, sofern es noch immer unerreichbar ist.14 |
Speicherbereinigung ist Aufgabe der JVM und damit abhängig von ihr
Die spezifischen Details, vor allem der letzten beiden Punkte, hängen von der jeweiligen JVM ab.
|
Das Verhalten des GCs unter einer Maschine ist nicht übertragbar auf die einer anderen oder auf eine zukünftige JVM. |
In der Klasse Object ist finalize() wie folgt deklariert:
protected void finalize() throws Throwable {}
Zu dieser quasi abstrakten Methode gibt es einen langen SUN-Kommentar, der neben der Beschreibung der Ausführung noch Hinweise auf die Verwendung gibt.
Genau diese Hinweise mögen auch ein Grund sein, anzunehmen, finalize() wäre ein Destruktor im Sinn von C++.
5.9.2 GC und Finalization
Nutzen der Speicherbereinigung mittels
finalize()
Will man in einer eigenen Klasse den GC nutzen,
|
muss man in der Klasse finalize() überschreiben. |
|
müssen Instanzen der Klasse unerreichbar werden. |
Beispiel
Nachfolgend wird in der Klasse TestGC anhand von finalize() getestet, wann der GC zumindest die ersten der beiden in 5.9.1 angegebenen Schritte ausführt.
Ein einfacher Test der Speicherbereinigung mit Hilfe von finalize()
class TestGC {
static int alive; // Anzahl der Instanzen, für die GC
// finalize() noch nicht aufgerufen hat
long[] larr;
TestGC(int arrSize) {
larr= new long[arrSize]; alive++;
}
protected void finalize() { // 2.Schritt:
System.out.print(--alive+" "); // alive anpassen
}
}
Die nachfolgende Klasse legt elf Instanzen der Klasse Test an, wobei mit ARR_SIZE der Speicherverbrauch pro Instanz variiert werden kann.
public class FinalizeTest {
final static int MAX_UNREACHABLE= 10;
final static int ARR_SIZE= 10000;
public static void main(String[] args) {
System.out.print("Nicht entsorgt: ");
for (int i=0; i<=MAX_UNREACHABLE; i++) {
TestGC t= new TestGC(ARR_SIZE);
}
System.out.println("Ende: " +TestGC.alive);
}
}
Erklärung: Da nur eine Referenz t zur Verfügung steht, wird nach jedem Iterationsschritt die vorherige Instanz unerreichbar, also zu einem GC-Kandidaten. Nach Beendigung der for-Schleife wird dies auch die letzte Instanz, da t eine lokale Variable ist. Alle Instanzen müssten also vor dem Programmende vom GC entsorgt werden.
Das Testergebnis auf der Konsole ist ernüchternd:
finalize(): ein nicht ermutigendes Ergebnis
Für ARR_SIZE<=1000 :: Aktiv: Ende: 11
Für ARR_SIZE=10000 :: Aktiv: 6 7 6 5 5 4 3
Ende: 4
Für ARR_SIZE=100000 :: Aktiv: 1 1 1 1 0 1 1
0 1 Ende: 2
:: 1
Die Speicherverwaltung der JVM ist unberechenbar
Fazit: Das Testergebnis ist nicht vorhersehbar und hängt – wie bereits gesagt – von der JVM und dem zur Verfügung stehenden Speicher ab und variiert von Aufruf zu Aufruf mit derselben ARR_SIZE.15
finalize(): ungeeignet für Ressourcen-Freigabe
Spätestens hier wird klar, dass finalize() schlichtweg nicht geeignet ist, wichtige System-Ressourcen wie Netzwerkverbindungen, Dateien, Fenster etc. zuverlässig freizugeben.
|
Ob der GC überhaupt läuft, d.h. für unerreichbare Instanzen die Methode finalize() aufruft, und in welcher Reihenfolge dies dann noch geschieht, ist unvorhersehbar. |
Eine ziemlich verrückte Folgerung daraus ist Code, der so lange für eine bestimmte JVM verändert wird, bis der GC endlich auf einer Testmaschine für alle wichtigen Objekte finalize() aufruft.16
5.9.3 Alternative zu finalize
Alternativen benötigt man nur dann, wenn man wichtige System-Ressourcen freigeben bzw. schließen muss. Ansonsten sollte man den GC in Ruhe arbeiten lassen.
Ressourcen-
Freigabe: release()-Methode und Cleanup-Idiom
|
Zur Freigabe von System-Ressourcen wird der Code von finalize() in eine release()- oder close()-Methode ausgelagert werden, die explizit und nicht mehr unvorhersehbar aufgerufen werden kann. |
|
Um sicherzustellen, dass zu einem Objekt die release()-Methode ausgeführt wird, wird das Cleanup-Idiom (siehe 3.3.4) verwendet.17 |
Diese Maßnahmen haben den Vorteil, dass die Freigabe nun unabhängig von der Objekt-Entsorgung ist, also auch bei Bedarf durchgeführt werden kann. Die release()-Methode wird zum Cleanup in den finally-Block gestellt.
Beispiel (Modifikation von TestGC)
Die Klasse TestGC in 5.9.2 wird mit einer release()-Methode ausgestattet. Die Anwendung, repräsentiert durch die Klasse FinalizeTest, wird mit Hilfe des Cleanup-Idioms angepasst:
Ein Beispiel:
Ressourcen-Freigabe mit release(), Cleanup
class TestGC {
private static int alive;
private long[] larr;
private boolean isReleased;
TestGC(int arrSize) {
larr= new long[arrSize];
alive++;
}
static int getAlive() { return alive; }
void release() {
if (!isReleased) { // verhindert doppelte Ausführung
// --- Freigabe/Schließen
von System-Ressourcen ---
System.out.print(--alive+" "); isReleased=
true;
}
}
}
public class FinalizeTest {
final static int MAX_UNREACHABLE= 10;
final static int ARR_SIZE= 0; // nun auch mit 0
Cleanup-Idiom
public static void main(String[] args) {
System.out.print("Released: ");
for (int i=0; i<=MAX_UNREACHABLE; i++) {
TestGC t= null;
try {
t= new TestGC(ARR_SIZE);
}
// Cleanup-Idiom
finally {
if (t!= null) t.release();
}
}
System.out.println("Ende: " +TestGC.getAlive());
}
}
Die Ausgabe ist nun unabhängig von ARR_SIZE und setzt zuverlässig alle Ressourcen frei, wie die Ausgabe zeigt:
:: Released: 0 0 0 0 0 0 0 0 0 0 0 Ende: 0
5.9.4 Einsatz von finalize
Der Nachteil der letzten Lösung ist der explizit notwendige Aufruf einer release()- bzw. close()-Methode in der Anwendung. Jedoch verbietet diese Lösung nicht den zusätzlichen Einsatz von finalize(), die den unbestreitbaren Vorteil der Automatik hat.
Für unerreichbare Objekte, bei denen der Aufruf der release()-Methode einfach »vergessen« wurde, gibt zumindest der GC mit finalize() die Ressourcen frei – sofern er denn in Aktion tritt.18
Im Trio:
Zusätzlich zu release() und Cleanup noch finalize()
Damit ist finalize() ein zusätzliches Sicherheitsnetz und nutzt dazu die bereits vorhandene release()-Methode, wobei sichergestellt sein muss, dass der Code in der release()-Methode nur einmal aufgerufen wird.
Beispiel (Weitere Modifikation von TestGC)
Die Klasse TestGC aus 5.9.3 wird nun zusätzlich mit einer finalize()-Methode ausgestattet:
class TestGC {
private static int alive;
private long[] larr;
private boolean isReleased;
TestGC(int arrSize) {
larr= new long[arrSize]; alive++;
}
static int getAlive() { return alive; }
synchronized:
Schutz vor gleichzeitiger Ausführung
synchronized void release() {
if (!isReleased) {
System.out.print(--alive+" "); isReleased= true;
}
}
protected void finalize()
throws Throwable {
System.out.print("f ");
release();
super.finalize(); // siehe 3. Regel unten
}
}
Ein Ergebnis,
das erfreut
Für ARR_SIZE=100000 interveniert nun ab und an der GC. Nachfolgend die Ausgabe:
:: Released:
0 0 f 0 f 0 f 0 f f 0 0 f 0 f f 0 0 f 0 f Ende: 0
Idiom zu finalize
Wird mit finalize() gearbeitet, sollten noch folgende vier Punkte beachtet werden, da man ansonsten mit versteckten Fehlern bestraft werden kann:
finalize()-Konventionen unbedingt beachten
1. |
finalize() sollte nicht direkt aufgerufen werden, dies ist einzig Aufgabe des GC. |
2. |
Die letzte Anweisung in der Implementierung von finalize() sollte immer super.finalize() sein. |
3. |
Die zugehörige Instanz sollte in finalize() nicht wieder erreichbar gemacht werden. |
4. |
Die release()-Methode, die finalize() aufruft, sollte synchronisiert sein. |
Erklärung:
ad 1: Kombiniert man eine public release()-Methode mit einer protected finalize()-Methode, so ist dies wohl gewährleistet und der direkte Aufruf unnötig.
ad 2: Im Gegensatz zu Konstruktoren, bei denen immer der Superklassen-Konstruktor implizit ausgeführt wird, wenn er nicht explizit geschrieben wird, gilt dies nicht für finalize().19
Sollten also irgendwelche »versteckten« Ressourcen freigesetzt werden müssen, die von Superklassen geerbt wurden, ist dies nur mit explizitem finalize()-Chaining zu realisieren.
ad 3: Wird in der finalize()-Methode eine gültige Referenz auf das Objekt gesetzt, ist es plötzlich wieder erreichbar. Dies veranlasst den GC dazu,
|
das Objekt nicht aus dem Speicher zu entfernen |
finalize() wird immer nur einmal aufgerufen
|
nicht wieder finalize() aufzurufen, wenn das Objekt wieder unerreichbar wird und entsorgt wird. |
Besonders der letzte Punkt muss unbedingt bedacht werden.
Sollte man mittels finalize() einen Pool von wiederverwendbaren Objekten aufbauen wollen, so ist dies keine gute Idee. Dies geht einfacher mit Soft-Referenzen.
ad 4: GC ist ein eigener Thread und ruft finalize() asynchron auf.
5.10 Kapselung (Encapsulation)
Kapselung: Ausgangspunkt aller OO-Design- Prinzipien
Das Kapitel soll mit einer kurzen Besprechung einer wichtigen Design-Methodik – der Kapselung – beendet werden.
Encapsulation: Es gibt keine direkten Zugriffe auf alle modifizierbaren Felder einer Klasse. |
Dies bedeutet, dass entweder alle Felder der Klasse final oder private deklariert sind.20 Zu den Feldern kommen eventuell noch Methoden hinzu, die man nur intern in der Klasse benötigt.
Nachteile der Kapselung
Kapselung:
Arbeits-/Zeit-Aufwand durch Getters und Setters
|
Für alle modifizierbaren Felder müssen Zugriffs-Methoden (accessor methods) geschrieben werden. Nach Java-Konvention haben sie die Namen getX(), isX() und setX(), wobei X der Name des Felds ist.21 |
|
Da jeder Datenzugriff nur über eine Methode gehen kann, wird zusätzlich die Ausführungsgeschwindigkeit vermindert. |
Vorteile der Kapselung
Kapselung:
code-unabhängig Schutz vor unerlaubten Werten
|
Die Details der internen Implementierung der Klasse werden geschützt, wodurch jederzeit eine Änderung möglich ist, ohne dass alle Anwender der Klasse ihren Code ändern müssen. |
|
Sind nur bestimmte Wertebereiche erlaubt, können nur private deklarierte Felder und Zugriffs-Methoden gegen unerlaubte Werte schützen. |
Fazit
Mit oder ohne Kapselung: geprüft werden muss ohnehin!
Jede klasseninterne und -externe Methode, die auf ungeschützte Felder zugreift, muss deren Gültigkeit prüfen, da sie ansonsten inkorrekte Ergebnisse liefern könnte.
Dies produziert dann genau wieder den Code bzw. Zeitverlust, den man durch Verzicht auf die Zugriffsfunktionen einsparen wollte.
Beispiel
Kapselung am Beispiel Teil
Die Klasse Teil zeigt exemplarisch den Einsatz von zwei setX()-Methoden.
Eigenschaften sind private
class Teil {
private int nr; // nicht alle Nr. sind erlaubt
private String bez; // keine Restriktion
private int bArtId; // sinnvoll: Eigenteil, Fremdteil
// Konstruktoren und andere Methoden werden weggelassen
public static boolean isValidNr(int nr) {
// kann recht komplex sein, hier eine Bereichsprüfung
return 1000000<=nr && nr<=9999999;
}
Setter für die Teile-Nr.
public boolean setNr
(int nr) {
// Alternative zu boolean: Exception
if (isValidNr(nr)) {
this.nr= nr;
return true;
}
return false;
}
Erweiterbarer Setter für
Beschaffungsart
public boolean setBeschaffungsart
(String bArt) {
boolean b= true;
bArt= bArt.toUpperCase();
if (bArt.equals("E")) bArtId= 1; // Eigenteil
else if(bArt.equals("F")) bArtId= 2; // Fremdteil
// else if(bArt.equals("EF")) bArtId= 3; ¨
else b= false;
return b;
}
}
Ergebnis ohne Kapselung: Die Felder nr oder bArtId können jederzeit mit unsinnigen Werten besetzt werden. Jede Methode, die dann auf nr oder bArtId zugreift, muss somit eine Prüfung einbauen, ob die Felder gültige Werte enthalten.
Ergebnis mit Kapselung: Die interne Darstellung bArtId der Beschaffungsart ist nach außen transparent und kann jederzeit gewechselt werden. Stellt man fest, dass es auch Teile geben kann, die fremd und eigen gefertigt werden können, ist die Erweiterung der Zugriffs-Methode setBeschaffungsart() problemlos (siehe ¨).
5.11 Zusammenfassung
Die Umsetzung der Generalisierung in Sub- und Superklassen in Java ist mit vielen sprachlichen Details verbunden.
Neben der Vererbung aller Member ist das Overriding – das Überschreiben von Instanz-Methoden – einer der wichtigsten Mechanismen.
Overriding ist unbedingt von Overloading und Shadowing zu unterscheiden. Statische Methoden können im Gegensatz zu Instanz-Methoden nicht überschrieben werden, obwohl dies umgangssprachlich genauso bezeichnet wird.
Eine Gruppe von Modifikatoren regelt die Art des Aufbaus einer Klassenhierarchie und den Zugriff. Verschiedene Modifikatoren können sich sinnvoll ergänzen oder auch gegenseitig ausschließen.
Eine syntaktisch eigene Gruppe von Konstruktoren regelt die Initialisierung von Objekten. Konstruktor-Chaining ist innerhalb einer Klasse sinnvoll und notwendig in der Klassenhierarchie, um die Initialisierung in der richtigen Reihenfolge zu erzwingen.
Destruktoren wie in C++ sind in Java unbekannt. Java hat eine automatische Speicherverwaltung GC, die den manuellen Aufruf von Destruktoren verhindern soll.
Der GC ruft zwar eine finalize()-Methode vor der Entfernung von Objekten aus dem Speicher aus, aber eine Speicherbereinigung von unerreichbaren Objekten kann nicht erzwungen werden.
Um wichtige Ressourcen freizugeben, wurden entsprechende Alternativen diskutiert.
Kapselung, der Schutz der Klassen-Implementierung vor dem direkten Zugriff von außen, ist das letzte von vielen Idiomen und Design-Prinzipien, die in diesem Kapitel vorgestellt wurden.
5.12 Testfragen
Zu jeder Frage können jeweils ein oder mehrere Antworten bzw. Aussagen richtig sein.
class A { void f(boolean p) {System.out.print (p+"
");}
void f(int i) {System.out.print (i+" ");} }
class B extends A { void f(boolean p){} }
Welche Aussagen sind zu folgendem Code-Fragment richtig?
B b= new B(); A a= (A)b;
a.f(1==1); // Zeile 2
b.f(1==1); // Zeile 3
b.f('1'); // Zeile 4
class A { void f(int i) {System.out.print (i);}
}
class B extends A{
void f(int i) {System.out.print (i+1); }
void f(byte b) {System.out.print (b==0); }
void f(char c) {System.out.print (c); }
}
Welche Aussagen sind zum nachfolgenden Code-Fragment richtig?
B b= new B(); A a= b;
class A {
int i;
A(int i) { this.i=i; }
}
class B extends A {
int i;
// B() { } // Zeile 7
// B() { this(0); } // Zeile 8
// B() { super(0); } // Zeile 9
B(int i) { super(i); this.i=i; }
}
class X {
static int si;
int i;
int[] iarr={1,2,3};
static {
// si= 1; // 6
// i= 1; // 7
}
{
// i=1; // 10
// System.out.println(iarr[0]); // 11
// d= 0.0; // 12
}
X() {
// System.out.println(i); // 15
// System.out.println(d); // 16
// d= 1.0; // 17
}
final double d; // 19
}
class A {
char c='A';
void f() { System.out.print(c+" "); }
void g() { System.out.print("Hi "); }
}
class B extends A {
char c='B';
void f() { System.out.print(c+" "); }
}
class C extends B {
// void f() {System.out.println(((A)this).c );} // 11
// void f() {System.out.println(((A)this).f());} // 12
// void g() {super.g(); System.out.print(super.c);} // 13
}
Welche Aussagen sind richtig?
1 Vgl. zur Implementierung Kapitel 6, Interfaces und Pattern.
2 Interfaces können nicht final deklariert werden.
3 Die Bedeutung von final für Variablen wurde bereits in 3.4.2 besprochen.
4 Vgl. Kapitel 6, Interfaces und Pattern.
5 Allerdings verwendet man bei statischen Methoden meistens auch den Begriff überschreiben.
6 Dies ist für C++-Programmierer recht ungewöhnlich, da nicht erlaubt.
7 Dank der Sprach-Designer von Java
8 Anonyme Klasse: Innere Klasse ohne Namen, siehe hierzu Kapitel 8, Innere Klassen
9 Deshalb kann bei der Klasse IntVector der Instanz-Initialisierer bzw. ein Konstruktor bereits auf statische Variablen zurückgreifen.
10 final dient somit nur der Klarheit und ist unnötig.
11 Leider spricht man auch bei statischen Methoden häufig von Overriding, obwohl dies nicht korrekt ist. Zu Shadowing siehe auch 5.8.2.
12 Ein Destruktor ist in C++ eine Instanz-Methode, die bei der Entsorgung nicht mehr benötigter Objekte garantiert aufgerufen wird. Im Gegensatz zu new gibt die Methode delete() den Speicher wieder frei.
13 Eventuell in Verbindung mit einem Aufruf von System.gc().
14 Denn durch finalize() könnte die Unerreichbarkeit ja aufgehoben werden.
15 Sollte die 1 in der letzten Zeile der letzten Ausgabe irritieren: Der GC ist ein asynchroner Thread, der auch noch nach dem main()-Thread aktiv sein kann und dadurch die Ausgabe in finalize() erzeugt (Näheres dazu in Kapitel 9, Threads).
16 Zum Beispiel durch Einfügen von sleep()-Anweisungen, um der GC-Thread Zeit zur Ausführung zu geben.
17 »Unbedingt« bedeutet auch die Ausführung nach Auftreten einer Ausnahme.
18 finalize() ist notwendig, aber nicht hinreichend (im mathematischen Sinn).
19 Dies ist ein weiteres Indiz gegen die Destruktor-Theorie.
20 protected als Zwischenform ist ein Zwitter, der von vielen als versteckter Bruch der Encapsulation angesehen wird.
21 Synonyme Begriffe für accessor methods sind deshalb: Getters und Setters
|