Block Image

Although Java 17 was released more than a year ago (14 September 2021), there are still many relatively new projects that use version 11 or even version 8.
With the recent release of Spring Boot version 3, developers have an incentive to switch to Java 17, as the new version of Spring supports Java 17 as the minimum version.
The transition from Java 11 to Java 17 is often quite painless. However, it would be more useful to also use the more recent features of this version. In this article, I list some features that I find to be the most useful.
Many of these features were not introduced in Java 17, but in versions later than Java 11.

Expression Switch

With the Expression Switch, the switch is no longer a statement, but an expression.
We can associate a return value with the switch!

Example:

boolean workDay = switch (dayOfWeek) {
    case SATURDAY, SUNDAY -> false;
    default -> true;
};

From the code above, we can see that the switch expression returns a value stored in the workDay variable.
The return value is preceded by the arrow operator.
One very important thing about the Expression Switch on Enums is that all possible cases must be evaluated, otherwise we will get a compilation error. We can bypass this constraint by adding a default case.

Block Image
Here the compiler gives an error because not all cases are evaluated.

Of course, we can also execute instructions before returning a value, like this:

boolean workDay = switch (dayOfWeek) {
    case SATURDAY, SUNDAY -> {
        System.out.println("do something");
        yield false;
    }
    default -> true;
};

Note that the keyword return is not used, but the contextual keyword yield.

NullPointerException clearer

If in Java 11 we had a NullPointerException like this:

Block Image

we could not know which value in line 41 was null (user or getAddress?).
With Java 17, the same exception is thrown by specifying exactly which field is null:

Block Image

Exception in thread "main" java.lang.NullPointerException: Cannot invoke 
"com.vincenzoracca.springbootrest.Java17$Address.getCity()" 
because the return value of 
"com.vincenzoracca.springbootrest.Java17$User.getAddress()" is null
at com.vincenzoracca.springbootrest.Java17.simulateNullPointer(Java17.java:76)
at com.vincenzoracca.springbootrest.Java17.main(Java17.java:23)

Thanks to the clarity of the Java 17 log, we know that it is getAddress that is null.
If user had been null instead, we would have had this log:

Exception in thread "main" java.lang.NullPointerException: Cannot invoke 
"com.vincenzoracca.springbootrest.Java17$User.getAddress()" because 
"user" is null
at com.vincenzoracca.springbootrest.Java17.simulateNullPointer(Java17.java:76)
at com.vincenzoracca.springbootrest.Java17.main(Java17.java:23)

Text Block

Text blocks are useful when we want to value a string in a particular format (e.g. JSON or XML).
For example, in Java 11 if we wanted to value a string with a formatted JSON, we would write this:

String json = "{\n  \"key\": \"value\" \n}";

The output is as follows:

Block Image

With Java 17, to get the same output we can write the code like this:

 String json = """
    {
      "key": "value"
    }""";

Text blocks begin and end with """ and allow the string to be formatted as it is written (we do not need, for example, to insert special characters such as \n to end).

Pattern Matching for instanceof

In Java 11, when an if expression containing the keyword instanceof is checked, we are forced to do a downcast if we want to use the variable by exploiting its actual subtype, as in this case:

if(userObject instanceof User) {
    User user = (User) userObject;
    System.out.println(user.getName());
}

With Java 17, we do not have to write boilerplate code:

if(userObject instanceof User user) {
    System.out.println(user.getName());
}

As the example above shows, we write the name of the variable directly into the expression.
You can use this variable throughout the scope in which the instanceof occurs, such as:

if(userObject instanceof User user && user.getName().equals("Enzo")) {
    System.out.println(user.getName());
}

Of course, if instead of the AND there had been the OR operator, it would not have been possible to use the user variable in the second condition, as it is not certain that the first condition is verified.
We can also use the user variable in this way:

if(! (userObject instanceof User user) ) {
    //
}
else {
    System.out.println(user.getName());
}

In this case the instanceof is verified in the else, so we can use the variable in that scope.

Records

In Java 17 we can use records, which are nothing more than final immutable classes. In addition, the compiler generates for us the equals, hashcode, getter (not in the "getVariableName()" manner but "variableName()") and toString methods.
The classic use case of records is in the use of DTOs, classes that map query results to the database or classes that map events.

Here is an example:

public record User(String name, Address address) {}

The same class in Java 11 (minus the final modifier) would be written like this:

public class User {
    private final String name;
    private final Address address;

    public User(String name, Address address) {
        this.name = name;
        this.address = address;
    }

    public String getName() {
        return name;
    }

    public Address getAddress() {
        return address;
    }

     @Override
     public boolean equals(Object o) {
         if (this == o) return true;
         if (o == null || getClass() != o.getClass()) return false;
         User user = (User) o;
         return Objects.equals(name, user.name) && Objects.equals(address, user.address);
     }

     @Override
     public int hashCode() {
         return Objects.hash(name, address);
     }

     @Override
     public String toString() {
         return "User{" +
                 "name='" + name + '\'' +
                 ", address=" + address +
                 '}';
     }
 }

With Java 17 we saved a lot of code, didn't we?

To retrieve the values of the name and address fields, we use this syntax:

System.out.println(user.name());
System.out.println(user.address());

We can customise the constructor in this way:

record User(String name, Address address) {

    public User {
        if(name == null || address == null) {
            throw new IllegalArgumentException();
        }
    }
}

Note that the constructor does not have the round brackets with the two parameters. We can also write it that way, but we would then have to initialise the fields ourselves, as in the classical way.

We can add other methods (instance or static) and override the default ones (equals, hashcode and toString), add static fields and custom constructors (as long as they call the constructor with all arguments).

Sealed interfaces/classes

With Sealed interfaces we can limit the classes that can implement an interface (the same applies to Sealed classes). Limiting sub-types can be useful for instance when we write a library and do not want our clients to be able to implement our interfaces with their classes (this can increase the security of our library).
In Java 17 we can easily do this:

public sealed interface Person permits Employee {

}

In the example above, we are saying that only the Employee class can implement the Person interface.
The Employee class will be written like this:

public final class Employee implements Person {

}

In particular:

  • Employee can be both a class and an interface
  • if Employee is not a final class, it must necessarily have the sealed modifier (so it must itself have subtypes extending it) or it must have the non-sealed modifier (in which case it can be extended by any subclass).

For example Employee could be written like this:

public non-sealed class Employee implements Person {
// any class can extend it
}
//or
public sealed class Employee implements Person permits ChiefEmployee {
// only the ChiefEmployee class can extend it
}
public final class ChiefEmployee extends Employee {

}

Stream.toList()

In Java 11, when we want to map a stream to a List, we must call the collect method and use the static method Collectors.toList() (importing the Collectors class):

import java.util.stream.Collectors;
...
var integers = List.of(1, 2, 3, 4);
var evenNumbers = integers.stream()
        .filter(number -> number % 2 == 0)
        .collect(Collectors.toList());

In Java 17, we can use the toList() method of Stream:

var integers = List.of(1, 2, 3, 4);
var evenNumbers = integers.stream()
        .filter(number -> number % 2 == 0)
        .toList();

Final considerations

In this short article, we saw which new features of Java 17 can be exploited by those coming from Java 11.
There are of course other features and other implicit advantages of Java 17, such as the improved performance of the Garbage Collector.
If you think there are other useful features to be exploited, please post them in the comments, it can help everyone!

Articles about Kubernetes: Kubernetes
Articles about Docker: Docker
Articles about Spring: Spring.

Recommended books: