Schlüsselworte final und sealed

Schlüsselwort final

Das Schlüsselwort final hat mehrere Bedeutungen

  • Eine finale Klasse kann keine Subklassen haben
  • Eine finale Methode kann nicht überschrieben werden
  • Eine finale Variable kann nicht mehr verändert werden

Finale Methoden

public class A {
    public void methode() {}
    public final void finalMethode() {}
}
public class B extends A {

    @Override
    public void methode() {}

    @Override
    public void finalMethode() {} // FEHLER!!
}

Finale Klasse

public final class A {

}
public class B extends A { // FEHLER!!

}

Finale Variablen

  • Finale Variablen können nicht mehr verändert werden
    • Finale Variablen primitiver Typen sind echte Konstanten
    • Finale Variablen unveränderlicher Typen verhalten sich ähnlich wie Konstanten
    • Finale Variablen veränderlicher Typen schützen das Objekt nicht vor Änderungen (sollte vermieden werden)
  • Die Zuweisung an eine final Variable muss nicht in der Deklaration erfolgen (blank final)
    • Der Compiler stellt sicher, dass die Variable auf allen möglichen Pfaden vor der ersten Verwendung genau einmal gesetzt wird

Eine finale Variable kann nachträglich nicht mehr geändert werden, wohl aber das Objekt auf das sie zeigt. final sorgt nur dafür, dass die Variable nicht mehr auf der linken Seite einer Zuweisung auftauchen kann.

Somit verhält sich final anders als das const in C++, bei dem man tatsächlich durch konsequentes Durchziehen des const-Modifiers durch alle Methoden, Parameter, Variablen und Klassen dafür sorgen kann, dass als const deklarierte Objekte nicht mehr verändert werden können. In Java hat man von diesem Ansatz wegen seiner Komplexität Abstand genommen. Die Folge ist aber, dass es keinen wirklichen Schutz vor unbeabsichtigter Veränderung von Objekten gibt, den man bei der Verwendung noch hinzufügen kann. Entweder ein Objekt ist von einem unveränderlichen Typ oder nicht.

public class Konstanten {

    // echte Konstante
    public static final double PI = 3.14159265;

    // echte Konstante
    public static final double NA = 6.0221415E23;

    // String ist immutable, verhält sich wie String-Konstante
    public static final String XML_HEADER =
            "<?xml version=\"1.0\"?>";

    // nur die Variable, nicht aber das Array sind geschützt
    public static final int[] PRIMZAHLEN = { 2, 3, 5, 7, 11, 13 };

}

In diesem Beispiel sind PI und NA echte Konstanten, weil sie von einem primitivem Typ sind. XML_HEADER verhält sich wie eine Konstante, weil das String-Objekt unveränderlich ist.

Die Variable PRIMZAHLEN ist zwar als final deklariert, dies bezieht sich aber nur auf die Variable selbst, nicht aber auf das dahinterliegende Array. Insofern kann man die Daten noch verändern, wie das folgende Beispiel zeigt.

public class Konstanten {
    public static final int[] PRIMZAHLEN =
            { 2, 3, 5, 7, 11, 13, 17, 19 };
}

public class Konstantenverwender {

    public static void main(String[] args) {
        System.out.println(Arrays.toString(Konstanten.PRIMZAHLEN));
        Konstanten.PRIMZAHLEN[2] = 8;
        System.out.println(Arrays.toString(Konstanten.PRIMZAHLEN));
    }
}
[2, 3, 5, 7, 11, 13, 17, 19]
[2, 3, 8, 7, 11, 13, 17, 19]

Das Beispiel zeigt, dass es sinnlos ist, final im Zusammenhang mit veränderlichen (mutable) Typen zu verwenden. Obwohl hier die Variable PRIMZAHLEN als final deklariert ist, kann man das Array auf das sie verweist problemlos modifizieren. In diesen Fällen sollte man das final am besten ganz weglassen, um keine falsche Sicherheit zu suggerieren.

Wenn man Listen von Objekten benötigt, die unveränderlich sind, bietet das Collection-API entsprechende Möglichkeiten, die später noch beleuchtet werden. Arrays sind hier auf jeden Fall ungeeignet.

Beispiel: Blank final

public class Kunde {

    private final int kundenNummer;

    public Kunde(int kundenNummer) {
        this.kundenNummer = kundenNummer;
    }
}

Das Beispiel zeigt, dass man das Setzen einer finalen Variablen durchaus herauszögern kann. Wichtig ist hier, dass die Variable auf jeden Fall genau einmal zugewiesen wird und danach nicht mehr. Der Compiler stellt dies sicher, indem er alle Ausführungspfade untersucht. Da hier die Variable kundenNummer im Konstruktor gesetzt wird, darf man sie als final deklarieren. Nach der Initialisierung erfolgt dann keine Veränderung mehr.

Finale Parameter und lokale Variablen

public void methode1(final int k) {
    k++; // FEHLER!!
}

public void methode2() {
    final boolean b = true;
    final String s;

    if (b) {
        s = "true";
    }
    else {
        s = "false";
    }
    s.toUpperCase();

    b = false; // FEHLER!!
}

Da es ohnehin als ausgesprochen schlechter Stil gilt, Methodenparameter innerhalb der Methode zu verändern, machen viele Programmierer:innen sie direkt bei der Deklaration final. Im vorliegenden Beispiel ist der Parameter k der Methode methode1 so deklariert. Der Versuch, die Variable innerhalb der Methode mit k++ zu ändern führt zu einem Compile-Fehler.

Der Grund für die Regel, dass man Parameter nicht verändern sollte, liegt darin, dass es das Debugging erschwert und den Quelltext schwerer lesbar macht. Wenn man eine Methode betrachtet, die mit einem bestimmten Parameterwert aufgerufen wurde, rechnet man nicht damit, dass dieser Parameter mitten in der Methode plötzlich einen anderen Wert hat.

In methode2 wird eine normale finale Variable (b) und ein blank final s verwendet. s wird dann, in Abhängigkeit von b, mit einem bestimmten Wert initialisiert. Auch hier überprüft der Compiler, ob bei allen Möglichen Pfaden durch das Programm die Variable genau einmal geschrieben wird.

Der Aufruf s.toUpperCase() ist unkritisch, weil er den Inhalt des Strings nicht verändert, sondern einen neuen String zurückgibt. Der Versuch, b noch einmal zuzuweisen schlägt dann aber mit einem Compiler-Fehler fehl.

Einkompilieren von Konstanten

Überraschende Optimierung

  • Konstanten, d. h. static final Variablen eines primitiven Typs, werden direkt in den Verwender einkompiliert, über Klassen und Paketgrenzen hinweg
  • Danach gibt es keine Referenz zwischen dem Anbieter der Konstanten und dem Verwender mehr
  • Ändert man die Konstante, so muss man zwingend die Verwender neu kompilieren

Dead Code Elimination

public class NoDeadCodeElemination {

    public static boolean SCHALTER = false;

    public static void main(String[] args) {
        if (SCHALTER) {
            System.out.println("Schalter AN");
        }
        else {
            System.out.println("Schalter AUS");
        }
    }
}

In diesem Beispiel ist die Variable SCHALTER nicht als static final deklariert und damit keine Konstante. Betrachtet man den vom Compiler erzeugten Bytecode (mithilfe von javap), so sieht man in der folgenden Ausgabe, dass die Bedingung noch vorhanden ist (Zeile 3).

public static void main(java.lang.String[]);
  Code:
   Stack=2, Locals=1, Args_size=1
   0:   getstatic       #10; //Field schalter:Z
   3:   ifeq    17
   6:   getstatic       #21; //Field java/lang/System.out
   9:   ldc     #27; //String Schalter AN
   11:  invokevirtual   #29; //Method java/io/PrintStream.println
   14:  goto    25
   17:  getstatic       #21; //Field java/lang/System.out
   20:  ldc     #35; //String Schalter AUS
   22:  invokevirtual   #29; //Method java/io/PrintStream.println
   25:  return

Das folgende Beispiel zeigt den Fall, dass die Variable SCHALTER eine echte Konstante ist.

public class DeadCodeElemination {

    public static final boolean SCHALTER = false;

    public static void main(String[] args) {
        if (SCHALTER) {
            System.out.println("Schalter AN");
        }
        else {
            System.out.println("Schalter AUS");
        }
    }
}

In diesem Fall eliminiert der Compiler den unnötigen und unerreichbaren Code und generiert ein Ergebnis ohne Bedingung. Unerreichbare Code wird auch als dead code bezeichnet und der hier vorgestellte Mechanismus daher als dead code elimination.

public static void main(java.lang.String[]);
  Code:
   Stack=2, Locals=1, Args_size=1
   0:   getstatic       #20; //Field java/lang/System.out
   3:   ldc     #26; //String Schalter AUS
   5:   invokevirtual   #28; //Method java/io/PrintStream.println
   8:   return

Sealed Classes

Bisher bekannte Möglichkeiten und Beschränkungen von Klassen

Art Instanzen Subklassen überschreiben
normale Klasse beliebig ja ja
Klasse mit privatem Konstruktor fest nein nein
abstrakte Klasse beliebig muss ja
Klasse mit finaler Methode beliebig ja ja, bis auf finale Methode
finale Klasse beliebig nein nein
Enumeration fest nein ja

Enumerationen werden erst später behandelt.

Man sieht an der Auflistung, dass die bisher bekannten Sprachmittel der Entwicklerin einer Klasse nur erlauben, Subklassen ganz zu verbieten oder eben jeder Klassen zu erlauben, Subklassen zu bilden. Dies ist für komplexe Softwareprojekte keine gute Lösung. Deswegen wurde mit Java 17 ein neuer Mechanismus eingeführt, mit dem eine Klasse selbst bestimmen kann, wer von ihr erben darf.

Java 17 führt Sealed Classes ein

  • Syntax: public sealed class <NAME> permits <CLASS>, <CLASS>, .. {
  • Superklasse kann festlegen, welche Klassen von ihr erben
  • Subklassen
    • müssen direkt von der Sealed Class erben
    • müssen im selben Modul liegen
    • müssen zur Compile-Zeit sichtbar sein
  • Interfaces können auch versiegelt werden

Sealed Classes wurden zwar bereits mit Java 15 testweise eingeführt (Preview Feature), sind aber erst seit Java 17 stabil und final in den Java-Standard aufgenommen.

Ziele

  • Dem Autor einer Klasse oder Schnittstelle Kontrolle darüber zu geben, welcher Code für die Implementierung verantwortlich ist.
  • Bereitstellung eines Verfahrens, die Verwendung einer Oberklasse einzuschränken.
  • Unterstützung für zukünftige Erweiterungen im Pattern Matching.

Ein Sealed Class in Java beschränkt die Möglichkeiten, wie eine Klasse erweitert werden kann. Eine solche Klasse kann nur von einer festgelegten Liste an Klassen erweitert werden, was sicherstellt, dass nur erwartete Typen verwendet werden. Dies ist nützlich, wenn man sicherstellen möchte, dass nur eine bestimmte Anzahl an Klassen einer bestimmten Hierarchie angehören.

Eine Klasse wird als sealed deklariert und eine Liste mit erlaubten Unterklassen wird angegeben.

Diese Klasse deklariert sich als Sealed Class und gibt an, welche Klassen von ihr erben dürfen: Circle, Square und Rectangle.

Versiegelte Klasse
public sealed class Shape
    permits Circle, Square, Rectangle {
}

Subklassen einer Sealed Class haben drei Möglichkeiten

  • sind final: Ende der Vererbungshierarchie
  • sind sealed: Erlauben bestimmte Subklassen
  • sind non-sealed: Öffnen sich wieder für beliebige Subklassen
Subklasse: final
public final class Circle extends Shape {
    public float radius;
}
Subklasse: versiegelt
public sealed class Rectangle extends Shape permits FilledRectangle {
    public double length, width;
}
Subklasse: nicht versiegelt
public non-sealed class Square extends Shape {
   public double side;
}

Die Klassen Circle, Square und Rectangle zeigen die drei Möglichkeiten für Subklassen einer Sealed Class:

  • Circle ist final, sodass keine anderen Klassen von Circle erben können.
  • Rectangle ist sealed erlaubt ausschließlich der Klasse FilledRectangle von ihr zu erben.
  • Square deklariert sich als non-sealed, sodass beliebige weitere Klassen von ihr erben können.

Copyright © 2025 Thomas Smits