Nachdem ich in JavaSPEKTRUM 4/2020 bereits über Dinge, die in Java funktionieren, aber sehr verwirrend sein können, geschrieben habe, habe ich das Thema letztes Jahr für einen Vortrag erneut aufgenommen. Bei der Vorbereitung und durch Feedback nach dem Vortrag habe ich noch weitere Besonderheiten gefunden, die wir uns im Folgenden anschauen wollen.
Ohne Semikolon?
Ob einem die Syntax einer Programmiersprache liegt oder nicht, ist natürlich Geschmackssache und vor allem Gewohnheit. Einige Sprachen verzichten dabei im Gegensatz zu Java auf das Semikolon am Ende eines Ausdrucks. Und doch ist es, wenn auch nur im Kleinen, möglich, ein Java-Programm zu schreiben, das etwas auf die Standardausgabe ausgibt, ohne ein Semikolon zu nutzen, wie Listing 1 zeigt.
public class Hallo {
public static void main(String[] args) {
if(System.out.append("Hallo\n") == null) {}
}
}Der Trick ist, dass die Methode append von java.io.PrintStream den übergebenen Wert als Seiteneffekt ausgibt und sich selbst zurückgibt. Diesen Rückgabewert können wir dann innerhalb einer if-Anweisung beispielsweise mit null vergleichen. Da die Bedingung im if kein Semikolon benötigt und wir durch den Seiteneffekt von append keinen Inhalt im true-Block brauchen, ist unser Programm somit frei von Semikolons. Neben append könnten wir auch printf nutzen. Das Konzept ist dabei identisch, denn auch printf gibt einen PrintStream zurück.
Ist das ein benannter Parameter?
Methoden oder Konstruktoren mit vielen Parametern werden beim Aufruf schnell unübersichtlich. Denn schnell ist, ohne Hilfsmittel der IDE, nicht mehr sichtbar, welcher Parameter jetzt was sein soll. In einigen Programmiersprachen gibt es deswegen das Konzept der benannten Parameter. Mit diesen spielt die Reihenfolge keine Rolle mehr, da die Parameter eben über ihren Namen gebunden werden. Java hat dieses Feature allerdings nicht, oder, siehe Listing 2, gibt es diese doch?
import java.util.Map;
public class NamedParameter {
public static void main(String[] args) {
var map = Map.of(
key = "1", value = "foo");
System.out.println(map);
}
}Nein, diese gibt es leider wirklich nicht. Ich habe in Listing 2, des Effektes wegen, eine Zeile weggelassen. Fügen wir diese hinzu und ergänzen das Map.of um ein zweites Paar, dann sehen wir in Listing 3, wie das Ganze funktioniert.
import java.util.Map;
public class NamedParameter {
static String key, value;
public static void main(String[] args) {
var map = Map.of(
key = "1", value = "foo",
key = "2", value = "bar");
System.out.println(map);
}
}Es ist in Java nämlich gleichzeitig möglich, eine Variable zuzuweisen und diese zu nutzen. Dadurch lässt sich in diesem Beispiel der Eindruck von benannten Parametern erzeugen. Diesen Effekt habe ich in der Praxis bisher vor allem beim Lesen eines java.io.BufferedReader mittels readLine, siehe Listing 4, gesehen.
import java.io.*;
public class ReadFile {
public static void main(String[] args)
throws IOException {
try (var reader = new BufferedReader(
new FileReader("/etc/hosts"))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
}
}Ohne benannte Parameter bleibt uns somit nur übrig, Methoden mit möglichst wenig Parametern, beispielsweise durch Einsatz eines Parameter-Objekts, zu schreiben oder für Konstruktoren das Builder-Muster zu verwenden.
Ein zerstörendes Inline Refactoring
Wir alle nutzen tagtäglich Refactorings, um unseren Code zu verbessern. Viele Refactorings gelten dabei als sicher. Das heißt, ich kann es anwenden, ohne Angst zu haben, dass ich damit etwas zerstöre. Eines dieser Refactorings ist Inline Variable, auch als Inline Temp bekannt. Hierbei wird eine Variable entfernt und an allen Stellen, an denen diese verwendet wird, wird die vorherige Zuweisung genutzt.
In diesem Rahmen bin ich auf einen Aufruf von Tagir Valeev bei X gestoßen. Die Aufgabe besteht darin, ein Java-Programm zu schreiben, bei dem das Inline Variable Refactoring dazu führt, dass der Code immer noch kompiliert, aber plötzlich eine Exception zur Laufzeit wirft, die vorher nicht geworfen wurde. Wir beginnen dabei mit dem Code aus Listing 5.
public class Inline {
public static void main(String[] args) {
int foo = foo(); // returns 42
use(foo); // prints "The answer is 42"
use(foo()); // throws Exception
}
// What to add here to make it work?
}Die Lösung besteht dabei im Ausnutzen des Auto-Boxing von Java für primitive Datentypen. So können wir die Methode foo mit dem Rückgabetyp Integer versehen. Durch die explizite Zuweisung zu einer int-Variable wird diese automatisch konvertiert, im Inline-Fall aber bleibt der Typ bei Integer. Fügen wir nun noch die Methode use einmal mit einem int-Parameter und einmal mit einem Integer-Parameter hinzu, siehe Listing 6, haben wir eine Lösung für die Aufgabe gefunden.
public class Inline {
// …
static Integer foo() {
return 42;
}
static void use(int i) {
System.out.println("The answer is " + i);
}
static void use(Integer i) {
throw new IllegalStateException("Boxing");
}
}Gleich ist nicht immer gleich
Eine der Sachen, die beim Erlernen von Java bereits früh vermittelt wird, ist, dass nur primitive Datentypen, und Enums, mit == verglichen werden sollen. So gibt das Programm aus Listing 7 zweimal true aus.
public class Boxing {
public static void main(String[] args) {
int i = 127;
int j = 127;
System.out.println(i == j);
i = 128;
j = 128;
System.out.println(i == j);
}
}Alle anderen Objekte sollten eben nicht mit ==, sondern mit dem Aufruf von equals verglichen werden. Das gilt auch für Wrapper-Typen wie Integer. Obwohl es hier Fälle gibt, in denen ein Vergleich mit == richtig funktioniert, aber eben nicht immer. So gibt der Code aus Listing 8 nicht zweimal true, sondern einmal true und einmal false aus.
public class Boxing {
public static void main(String[] args) {
Integer i = 127;
Integer j = 127;
System.out.println(i == j);
i = 128;
j = 128;
System.out.println(i == j);
}
}Dieses Verhalten liegt daran, dass es für Integer einen internen Cache gibt, welcher für Werte von -128 bis +127 stets dieselbe Instanz zurückgibt. Da wir mit == die Instanz vergleichen, funktioniert dies für genau diesen Bereich, aber nicht für Werte außerhalb des Cache-Bereichs.
Ist es aber möglich, dass der Code aus Listing 8 zweimal true ausgibt, ohne ihn zu ändern? Ja, denn die Größe des Caches lässt sich durch eine Konfigurationsoption beim Starten der JVM verändern. So gibt der Aufruf java -XX:AutoBoxCacheMax=500 Boxing.java zweimal true aus. Ich würde jedoch davon abraten, diesen Wert zu verändern, um sich darauf verlassen zu können, dass Integer anschließend mit == korrekt verglichen werden können.
Ist 1 + 1 wirklich 2?
Wir nutzen die Aussage „Eins plus eins ist zwei“ in der Regel dazu, darauf hinzuweisen, dass eine Aussage trivial ist. Doch ist das in Java wirklich immer so? Der Code aus Listing 9 gibt, mit dem passenden Trick, plötzlich nicht mehr 2, sondern 42 aus.
public class One {
// Insert some magic here!
public static void main(String[] args) {
Integer x = 1;
System.out.println(x + x);
}
}Der Trick besteht aus einer Kombination des vorherigen Verhaltens und dem Trick, Strings zu verändern wie in meinem alten Artikel. Denn der schon erwähnte Cache für Integer-Werte lässt sich über Reflection manipulieren, siehe Listing 10.
public class One {
static {
try {
var clazz = Class.forName("java.lang.Integer$IntegerCache");
var field = clazz.getDeclaredField("cache");
field.setAccessible(true);
var cache = (Integer[]) field.get(clazz);
cache[129] = 21;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) {
Integer x = 1;
System.out.println(x + x);
}
}Wie auch beim Verändern von Strings müssen wir, damit dies funktioniert, beim Starten der JVM die Klassen aus java.lang mit dem Konfigurationsparameter --add-opens java.base/java.lang=ALL-UNNAMED für unseren Code öffnen.
Noch ein wenig magischer wirkt dieser Trick übrigens, wenn wir nicht System.out.println, sondern die ab JDK 24 als Preview vorhandene Methode IO.println nutzen, siehe Listing 11.
import java.io.IO;
public class One {
static {
try {
var clazz = Class.forName("java.lang.Integer$IntegerCache");
var field = clazz.getDeclaredField("cache");
field.setAccessible(true);
var cache = (Integer[]) field.get(clazz);
cache[130] = 42;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) {
IO.println(1 + 1);
}
}Das Ganze funktioniert nun ohne die explizite Integer-Variable, da es hier keine spezifische Überladung von IO.println für int gibt, sondern nur eine Variante mit Object als Typ für den Parameter. Wer genau hinschaut, erkennt hier noch eine zweite kleine Änderung. Während wir in Listing 10 den Wert von 1 im Cache auf 21 gesetzt haben, setzen wir in Listing 11 direkt den Wert von 2 auf 42. Dies ist notwendig, da der Compiler den Ausdruck 1 + 1 bereits selbst evaluiert und somit zur Laufzeit der Ausdruck IO.println(2) ausgeführt wird.
Mehr als nur ein Check auf den Wertebereich
Und wir bleiben bei primitiven Datentypen. Seit JDK 23 gibt es, als Preview-Feature, die Möglichkeit, auch primitive Datentypen in Patterns, instanceof und switch, zu nutzen. So läuft der Code aus Listing 12 in beiden Fällen in den if-Block, denn egal, als welchen Typ ich den Wert 16.277.216 definiere, er kann als float repräsentiert werden.
public class Bounds {
public static void main(String[] args) {
int i = 16_777_216;
if (i instanceof float f) {
System.out.println("Match float for int with value: " + f);
}
float fl = 16_777_216;
if (fl instanceof float f) {
System.out.println("Match float for float with value: " + f);
}
}
}Doch was passiert, wenn beide Variablen um eins, auf 16.277.217, erhöht werden. Plötzlich verändert sich die Ausgabe und wir erhalten das Ergebnis aus Listing 13.
$ java --enable-preview Bounds.java
Found float with value: 1.6777216E7Der eigentliche Wert 16.777.217 passt demnach zwar in einen float, aber trotzdem kann dieser nicht sicher konvertiert werden, und dementsprechend schlägt der instanceof-Check fehl. Dieses neue Feature ist eben mehr als nur eine reine Prüfung auf Wertebereiche.