Meet Java 17

Java… what version is it?17 reached GA on 14.09.2021 and brought a bunch of new features, of which two are of more interest than the rest, at least for me. These are:

  • sealed classes
  • pattern matching for switch (first preview)

Java 17 is a LTS version.

Sealed classes

Sealed classes came in Java 17 via JEP 409 and although the name says “sealed classes” , interfaces are also subject to being sealed. With this new feature, three new special keywords have been added to the language: sealed, non-sealed and permits. The basic idea is that sealed classes and interfaces restrict which other classes or interfaces may extend or implement them. Let’s look at an example:

sealed class A permits A1, A2 {}
final class A1 extends A {}
sealed class A2 extends A permits B {}
non-sealed class B extends A2 {}
class X extends B {}

Class A is sealed and allows only classes A1 and A2 to extend it. Both A1 and A2 do that, but A2 is again declared as sealed, allowing only class B to extend it. Class B does that and, additionally, is declared as non-sealed, which means its part of the hierarchy reverts to being open for extension by unknown subclasses. Class X can extend B without any problems. One interesting aspect is that every permitted class must use a modifier to describe how it propagates the sealing initiated by its superclass, that is either final, sealed or non-sealed must be specified, any combination of these being invalid.

The same idea applies to interfaces too. Let’s take for instance this structure:

sealed interface I permits J {}
sealed interface J extends I permits C, D {}
final class C implements J {}
record D() implements J {}

Interface I permits to be extended only by interface J, which is also a sealed interface permitting only classes C and D to implement it. J must be declared either as sealed or non-sealed. Class C must also be either final , sealed or non-sealed. The interesting piece is class D, which is a record. Records are by construct final, so the condition above still holds. Note that D cannot extend any other class, it can only implement interfaces, the reason is that a record already extends another class: java.lang.Record. You can convince yourself of this by disassembling class D using javap D and looking at what the compiler did for you:

final class D extends java.lang.Record implements J

Sealed classes allow us to model a fixed set of kinds of values, similar to how enum classes allow us to model the situation where a given class has only a fixed number of instances. You will be seeing sealed classes in pattern matching for switch because they support the exhaustive analysis of patterns.

Pattern matching for switch (first preview)

Pattern matching, JEP 406 for switch is one of the features that fall under the umbrella of pattern matching. This feature is in its first preview in Java 17, which mean you have to activate it before being able to actually use it. At the time of this writing there are two other previews dealing with this: JEP 420 - Java 18 and JEP 427 - Java 19. Before going into some of the aspects of pattern matching for switch, let’s see what pattern matching itself wants to be.

Pattern matching(PM) is part of the Amber project, which has the goal of exploring and incubating smaller productivity-oriented Java language features that have been accepted as candidate JEPs. PM is not a new thing under the sun, according to Wikipedia it goes as back as 1950, but it is a new thing in Java. The very basic idea is to check something for the existence of some pattern and take some actions if there is a match. A pattern is a combination of a match predicate that determines if the pattern matches a target, along with a set of pattern variables that are conditionally extracted if the pattern matches the target.

Now, this idea of matching against something and taking some action if there is a match might make you think about the instanceof operator and the switch statement or expression. Java 12 introduced the switch expression which was finalized and made permanent in Java 14.

Long story short, you can now write code as below, wherein the second if makes use of pattern matching for instanceof:

Object obj = "Hello World";
if (obj instanceof String) {
    String s = (String) obj;
    // do something with s, e.g. s.length()
}
if (obj instanceof String s) {
    // do something with s, e.g. s.length()
}

Before looking at how pattern matching for switch looks like, let’s remind ourselves how a switch expression looks like.

Given an enum house representing the Hogwarts houses, we can get the house’s founder using a switch expression.

return switch (house) {
    case GRYFFINDOR -> "Godric Gryffindor";
    case HAFFLEPUFF -> "Helga Hufflepuff";
    case RAVENCLAW -> "Rowena Ravenclaw";
    case SLYTHERIN -> "Salazar Slytherin";
    case null -> "not a house";
};

Note the separate handling for the null case!

Make sure to check the JEPs above as there is a lot going on regarding switch.

Now, la pièce de résistance is using pattern matching for switch with sealed classes. Let’s see how that looks like.

Given the following pieces of code:

public abstract sealed class HogwartsHouse permits Gryffindor, Hafflepuff, Ravenclaw, Slytherin {
    public abstract String name();
    public abstract String colors();
    public abstract String founder();
    public abstract String animal();
    public String details() {
        return String.format("%-13s - %-20s - %-20s - %-10s", name(), colors(), founder(), animal());
    }
}

public final class Gryffindor extends HogwartsHouse {
    @Override public String name() { return "Gryffindor"; }
    @Override public String colors() { return "Scarlet and Gold"; }
    @Override public String founder() { return "Godric Gryffindor"; }
    @Override public String animal() { return "Lion"; }
}

// The other houses (Hufflepuff, Ravenclaw, Slytherin) have been left out for the sake of brevity.
// They all extend HogwartsHouse and implement the methods accordingly.

we can write something like this to get the details of every house:

public class HogwartsSchool {
    public static void main(String[] args) {
        Stream.of(new Gryffindor(), new Hafflepuff(), new Ravenclaw(), new Slytherin(), null)
            .map(HogwartsSchool::buildDetails).forEach(System.out::println);
    }

    static String buildDetails(HogwartsHouse house) {
        return switch (house) {
            case Gryffindor gryffindor -> {
                var details = gryffindor.details();
                yield details.concat("students: Harry, Hermione, Ron");
            }
            case Hafflepuff hafflepuff -> hafflepuff.details();
            case Ravenclaw ravenclaw -> ravenclaw.details();
            case Slytherin slytherin -> slytherin.details();
            case null -> "not a house";
        };
    }
}

which produces this result:

Gryffindor    - Scarlet and Gold     - Godric Gryffindor    - Lion      students: Harry, Hermione, Ron
Hafflepuff    - Yellow and Black     - Helga Hufflepuff     - Badger    
Ravenclaw     - Blue and Bronze      - Rowena Ravenclaw     - Eagle     
Slytherin     - Green and Silver     - Salazar Slytherin    - Snake     
not a house

You might have noticed, that the building details logic can be shortened quite a bit by just calling house.details() and leaving the polymorphism do its thing, plus adding some extra bit for the case the house is a Gryffindor one. Nonetheless, a house can only be one of those four types, and each pattern variable is of the correct subtype. Without sealing the house, that code would not have compiled. Notice that we are not comparing against a specific set of values, but against a specific set of types.

Suppose now that we change the signature of the method to be this: static String buildDetails(Object house), i.e. instead of HogwartsHouse we have Object for the type of the house. This requires us to account for the missing cases, i.e. all the other possible objects that could be a house, arrays, lists, etc., which of course is nonsensical. The solution is to change the last case to this: case default, null -> "not a house";. Now, we are all good, the code compiles and we get the same result as before. Again, notice that we are comparing against a set of possible types, just as before.

Do not forget to enable the preview features, in IntelliJ IDEA is as simple as selecting the language level for the project to be 17 (Preview) - Pattern matching for switch.

From the command line it looks like this:

javac HogwartsHouse.java Gryffindor.java Ravenclaw.java Hafflepuff.java Slytherin.java HogwartsSchool.java --release 17 --enable-preview
java --enable-preview HogwartsSchool

Java 17’s predecessors

From here on I’ll be touching upon some other features/language improvements which came in the language in different previous versions, which I find most interesting.

Local Variable Type Inference

In a mortal’s language this just means that there is a new way of declaring local variables, i.e. variables inside a method, without having to specify the type, in which case the type is inferred, if possible. The magic keyword is var and ca be used in instance methods, in static methods, it cannot be used for field initialization nor in cases where the compiler thinks that there is not enough information to infer the type(initializing with null, no initialization, return type).

var aString = "Hello, World!"; // Ok
var aListOfStrings = new ArrayList<String>(); // Ok
var aNull = null; // Ko
var notInitialized; // Ko

Private Methods in Interfaces

Besides default and static methods in interfaces, since Java 9 it is also possible to add private methods. So, that code which should not be available for everyone, should be put inside a private method. Given the following interface and implementation:

interface Interface {
    default void d() { p(); }
    static void s() { ps(); }
    private void p() { }
    private static void ps() { }
}

class InterfaceImpl implements Interface {}

we have the following:

new InterfaceImpl().d(); // Ok
Interface.s(); // Ok
new InterfaceImpl().p(); // Ko - not available to call
Interface.ps(); // Ko - not available to call

Records

Java 16 brought us records, a.k.a a sort of data transfer object, or data carrier. A record automatically generates:

  • immutable fields for each of its components
  • a canonical constructor
  • accessor methods for all of its components
  • equals()
  • hashCode()
  • toString()
interface Wizard {
    void performSpell();
}

record HogwartsStudent(String name, House house) implements Wizard {

    // this is the compact constructor (optional) used to perform name validation
    HogwartsStudent {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Must specify a name for the student.");
        }
        // adjustment to field can only happen in the constructor
        name = "Dr. " + name;
    }

    @Override
    public void performSpell() {
        System.out.println("Perform a spell");
    }
}

var harry = new HogwartsStudent("Harry Potter", House.GRYFFINDOR);
System.out.println(harry.name()); // prints Dr. Harry Potter
System.out.println(harry.house()); // prints GRYFFINDOR
harry.performSpell();

Let’s look now at what the compiler does for us (javap HogwartsStudent.class):

final class HogwartsStudent extends java.lang.Record {
  HogwartsStudent(java.lang.String, House);
  public final java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public java.lang.String name();
  public House house();
}

In a nutshell, these are some of the important aspects of records:

  • a record is a final class (=> a record cannot be abstract)
  • fields are private and final
  • it already extends the Record class, so you cannot have a record extend any other classes
  • it cannot be extended by any other class
  • it can implement interfaces
  • we have the canonical constructor taking in a string and an enum, matching the given components
  • an extra constructor can be added, the compact constructor, which is typically used to validate the arguments
  • we can replace the canonical constructor with a normal constructor, but its signature must exactly duplicate the signature of the record including the identifier names
  • we have the accessors (no more in the JavaBean style)
  • we have the equals(), hashCode() and toString() methods
  • the only way to add fields to a record is by defining them in the header, however static methods, fields and initializers are allowed

Better NullPointerException Reporting

Another improvement, which was finalized in Java 15, concerns the error message we see when a NPE hits us. Before, we would have just seen a rather poor message, something along the lines of java.lang.NullPointerException, with no indications as to why it happens. The new message offers us more information, so in the case of a chain like this a.b.c, where b is null, we would get something like: java.lang.NullPointerException: Cannot read field "c" because "...b" is null. This makes it significantly easier to understand and resolve such exceptions.

Text Blocks

Java 15 also brought us text blocks, which is a feature lifted from the Python language. Triple quotes denote a block of text including newlines. We can now write something like this:

var content = """
    {
        "key1":"value1",
        "key2":"value2",
    }""";
System.out.println(content);

var sameContent = "{\n" +
        "    \"key1\":\"value1\",\n" +
        "    \"key2\":\"value2\",\n" +
        "}";
System.out.println(content.equals(sameContent)); // => true

This produces:

{
    "key1":"value1",
    "key2":"value2",
}

Before Java 15 that code requires a lot of string concatenation and newlines to get the same result. For longer texts it is even more complicated to work with. Notice that the newline after the first """ is automatically removed, and the final """ is placed on the same line and directly after the closing } of the json, this is so to prevent another newline.

Starting with the same version, the string objects have a formatted() method, which we can use the same way as String.format().

System.out.println("""
        name: %s
        profession: %s""".formatted("Vlad", "Software Engineer"));

which produces:

name: Vlad
profession: Software Engineer

Same result can be produced by using the printf method instead of the println:

System.out.printf("""
        name: %s
        profession: %s%n""", "Vlad", "Software Engineer");

As you can see, Java is making progress rapidly and brings in features which ease the programmer’s work and make the language overall more appealing to learn and use. There is a lot of work happening right now, Java 18 is already out, with 19 coming in autumn this year. Besides Amber, other Java projects, Loom, Valhalla, Panama, promise a lot more and will make the language even more appealing and worth learning it.