Igor's Techno Club

Migrating from Java 8 to Java 17: A Comprehensive Guide to New Features

Java 17 vs Java 8

Java has evolved significantly since the release of Java 8 in 2014. Java 17, released in 2021, brings a host of new features and improvements that can enhance your code's readability, maintainability, and performance. This article will guide you through the key features introduced between Java 8 and Java 17, providing examples of how to migrate your code to take advantage of these new capabilities.

Sealed Classes

Java 17 introduces sealed classes, a powerful feature that enhances Java's object-oriented programming model and works seamlessly with pattern matching in switch expressions.

New Syntax:

The sealed keyword declares a sealed class or interface, and the permits clause specifies allowed subclasses:

public sealed interface Shape permits Circle, Rectangle, Triangle { }

Subclasses must use one of three modifiers: final, sealed, or non-sealed.

When to Use:

Use sealed classes when you want to:

  1. Restrict class hierarchy extensions
  2. Define a fixed set of subtypes
  3. Enable exhaustive pattern matching in switch expressions
  4. Model domain-specific concepts more accurately

Examples:

  1. Shape hierarchy with exhaustive switch:

    sealed interface Shape permits Circle, Rectangle, Triangle { }
    record Circle(double radius) implements Shape { }
    record Rectangle(double width, double height) implements Shape { }
    record Triangle(double base, double height) implements Shape { }
    
    static double calculateArea(Shape shape) {
        return switch (shape) {
            case Circle c -> Math.PI * c.radius() * c.radius();
            case Rectangle r -> r.width() * r.height();
            case Triangle t -> 0.5 * t.base() * t.height();
        }; // No default needed, switch is exhaustive
    }
    
  2. Payment processing with pattern matching:

    sealed interface Payment permits CreditCard, DebitCard, Cash { }
    record CreditCard(String number, String name) implements Payment { }
    record DebitCard(String number, String bank) implements Payment { }
    record Cash(double amount) implements Payment { }
    
    static void processPayment(Payment payment) {
        switch (payment) {
            case CreditCard cc -> System.out.println("Processing credit card: " + cc.number());
            case DebitCard dc -> System.out.println("Processing debit card from: " + dc.bank());
            case Cash c -> System.out.println("Accepting cash payment of: $" + c.amount());
        }
    }
    
  3. Expression evaluator with sealed classes:

    sealed interface Expr permits Literal, Addition, Multiplication { }
    record Literal(int value) implements Expr { }
    record Addition(Expr left, Expr right) implements Expr { }
    record Multiplication(Expr left, Expr right) implements Expr { }
    
    static int evaluate(Expr expr) {
        return switch (expr) {
            case Literal l -> l.value();
            case Addition a -> evaluate(a.left()) + evaluate(a.right());
            case Multiplication m -> evaluate(m.left()) * evaluate(m.right());
        };
    }
    

Sealed classes in Java 17 enable developers to create more robust and expressive class hierarchies. They work particularly well with pattern matching in switch, allowing for concise, type-safe, and exhaustive handling of different subtypes. By upgrading to Java 17 and utilizing sealed classes, you can write cleaner, more maintainable code with improved static analysis capabilities.

Records

Java 17 finalizes the records feature, first introduced as a preview in Java 14. Records provide a compact way to declare classes that are transparent holders for shallowly immutable data.

New Syntax:

A record declaration consists of a name and a list of components:

record Point(int x, int y) { }

This concise declaration automatically provides:

When to Use:

Use records when you want to:

  1. Create simple data carrier classes
  2. Represent immutable data
  3. Reduce boilerplate code
  4. Enhance readability and maintainability

Examples:

  1. Representing a person with pattern matching in switch:

    record Person(String name, int age) { }
    
    static String classifyPerson(Person person) {
        return switch (person) {
            case Person p when p.age() < 18 -> "Minor";
            case Person p when p.age() >= 18 && p.age() < 65 -> "Adult";
            case Person p when p.age() >= 65 -> "Senior";
            default -> "Unknown";
        };
    }
    
  2. Using records in collections:

    record TradeOrder(String symbol, int quantity, double price) { }
    
    List<TradeOrder> orders = List.of(
        new TradeOrder("AAPL", 100, 150.0),
        new TradeOrder("GOOGL", 50, 2800.0)
    );
    
    double totalValue = orders.stream()
        .mapToDouble(order -> order.quantity() * order.price())
        .sum();
    
  3. Nested records for complex data structures:

    record Address(String street, String city, String country) { }
    record Employee(String name, int id, Address address) { }
    
    Employee emp = new Employee("John Doe", 1001, 
        new Address("123 Main St", "New York", "USA"));
    
    System.out.println("Employee city: " + emp.address().city());
    

Upgrade from Java 8:

For developers transitioning from Java 8 to Java 17, records offer a significant improvement in how data-carrying classes are written. In Java 8, you'd typically use a class with numerous fields, constructors, getters, and overridden Object methods. Let's compare a Java 8 approach with the new record syntax:

Java 8 version:

public class Customer {
    private final String name;
    private final String email;
    private final int age;

    public Customer(String name, String email, int age) {
        this.name = name;
        this.email = email;
        this.age = age;
    }

    public String getName() { return name; }
    public String getEmail() { return email; }
    public int getAge() { return age; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Customer customer = (Customer) o;
        return age == customer.age &&
               Objects.equals(name, customer.name) &&
               Objects.equals(email, customer.email);
    }

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

    @Override
    public String toString() {
        return "Customer{" +
               "name='" + name + '\'' +
               ", email='" + email + '\'' +
               ", age=" + age +
               '}';
    }
}

Java 17 record version:

public record Customer(String name, String email, int age) { }

This single line of code in Java 17 provides the same functionality as the entire class in Java 8. The record automatically generates the constructor, accessor methods, equals, hashCode, and toString methods. This dramatic reduction in boilerplate code improves readability, reduces the potential for errors, and allows developers to focus on the essential aspects of their data models.

Records in Java 17 offer a way to create simple, immutable data classes with minimal boilerplate. They work well with pattern matching, streams, and other modern Java features. By upgrading to Java 17 and utilizing records, you can write more concise, readable, and maintainable code, especially when dealing with data transfer objects or value types.


For more content like this, subscribe to the blog



Pattern Matching for instanceof

Java 17 finalizes the pattern matching for instanceof feature, which simplifies conditional processing based on object types and their properties.

New Syntax:

The new syntax combines the instanceof check with a declaration:

if (obj instanceof String s) {
    // Use s directly as a String
}

This replaces the traditional:

if (obj instanceof String) {
    String s = (String) obj;
    // Use s
}

When to Use:

Use pattern matching for instanceof when you want to:

  1. Simplify type checking and casting
  2. Improve code readability
  3. Reduce the risk of errors from explicit casting
  4. Make polymorphic code more concise

Examples:

  1. Shape area calculation:

    interface Shape { }
    record Circle(double radius) implements Shape { }
    record Rectangle(double width, double height) implements Shape { }
    
    static double calculateArea(Shape shape) {
        if (shape instanceof Circle c) {
            return Math.PI * c.radius() * c.radius();
        } else if (shape instanceof Rectangle r) {
            return r.width() * r.height();
        }
        return 0.0;
    }
    
  2. Enhanced exception handling:

    try {
        // Some code that might throw exceptions
    } catch (Exception e) {
        String message = switch (e) {
            case IOException io -> "IO error: " + io.getMessage();
            case NumberFormatException nfe -> "Invalid number format: " + nfe.getMessage();
            default -> "Unexpected error: " + e.getMessage();
        };
        System.err.println(message);
    }
    
  3. Processing heterogeneous collections:

    List<Object> items = Arrays.asList("Hello", 42, 3.14, "World");
    
    for (Object item : items) {
        if (item instanceof String s) {
            System.out.println("String: " + s.toUpperCase());
        } else if (item instanceof Integer i) {
            System.out.println("Integer: " + (i * 2));
        } else if (item instanceof Double d) {
            System.out.println("Double: " + Math.round(d));
        }
    }
    

Migration from Java 8:

Here are some examples of migrating from Java 8 to Java 17, specifically focusing on the pattern matching for instanceof feature:

  1. Processing different types of vehicles:

    Java 8:

    public void processVehicle(Vehicle vehicle) {
        if (vehicle instanceof Car) {
            Car car = (Car) vehicle;
            System.out.println("Car with " + car.getDoors() + " doors");
        } else if (vehicle instanceof Motorcycle) {
            Motorcycle motorcycle = (Motorcycle) vehicle;
            System.out.println("Motorcycle with " + motorcycle.getEngineCapacity() + "cc engine");
        } else if (vehicle instanceof Bicycle) {
            Bicycle bicycle = (Bicycle) vehicle;
            System.out.println("Bicycle with " + bicycle.getGears() + " gears");
        }
    }
    

    Java 17:

    public void processVehicle(Vehicle vehicle) {
        if (vehicle instanceof Car car) {
            System.out.println("Car with " + car.getDoors() + " doors");
        } else if (vehicle instanceof Motorcycle motorcycle) {
            System.out.println("Motorcycle with " + motorcycle.getEngineCapacity() + "cc engine");
        } else if (vehicle instanceof Bicycle bicycle) {
            System.out.println("Bicycle with " + bicycle.getGears() + " gears");
        }
    }
    
  2. Handling different types of exceptions:

    Java 8:

    try {
        // Some code that might throw exceptions
    } catch (Exception e) {
        if (e instanceof IOException) {
            IOException ioException = (IOException) e;
            System.err.println("IO Error: " + ioException.getMessage());
        } else if (e instanceof SQLException) {
            SQLException sqlException = (SQLException) e;
            System.err.println("Database Error: " + sqlException.getSQLState());
        } else if (e instanceof NumberFormatException) {
            NumberFormatException nfe = (NumberFormatException) e;
            System.err.println("Number Format Error: " + nfe.getMessage());
        } else {
            System.err.println("Unexpected Error: " + e.getMessage());
        }
    }
    

    Java 17:

    try {
        // Some code that might throw exceptions
    } catch (Exception e) {
        if (e instanceof IOException io) {
            System.err.println("IO Error: " + io.getMessage());
        } else if (e instanceof SQLException sql) {
            System.err.println("Database Error: " + sql.getSQLState());
        } else if (e instanceof NumberFormatException nfe) {
            System.err.println("Number Format Error: " + nfe.getMessage());
        } else {
            System.err.println("Unexpected Error: " + e.getMessage());
        }
    }
    
  3. Processing a list of shapes:

    Java 8:

    public double calculateTotalArea(List<Shape> shapes) {
        double totalArea = 0;
        for (Shape shape : shapes) {
            if (shape instanceof Circle) {
                Circle circle = (Circle) shape;
                totalArea += Math.PI * circle.getRadius() * circle.getRadius();
            } else if (shape instanceof Rectangle) {
                Rectangle rectangle = (Rectangle) shape;
                totalArea += rectangle.getWidth() * rectangle.getHeight();
            } else if (shape instanceof Triangle) {
                Triangle triangle = (Triangle) shape;
                totalArea += 0.5 * triangle.getBase() * triangle.getHeight();
            }
        }
        return totalArea;
    }
    

    Java 17:

    public double calculateTotalArea(List<Shape> shapes) {
        double totalArea = 0;
        for (Shape shape : shapes) {
            if (shape instanceof Circle circle) {
                totalArea += Math.PI * circle.getRadius() * circle.getRadius();
            } else if (shape instanceof Rectangle rectangle) {
                totalArea += rectangle.getWidth() * rectangle.getHeight();
            } else if (shape instanceof Triangle triangle) {
                totalArea += 0.5 * triangle.getBase() * triangle.getHeight();
            }
        }
        return totalArea;
    }
    

These examples demonstrate how pattern matching for instanceof in Java 17 simplifies the code by combining the type check and casting into a single step. This results in more concise and readable code, reducing the risk of errors from explicit casting and making the code easier to maintain.

Pattern matching for instanceof in Java 17 offers a more elegant way to work with polymorphic code. It reduces boilerplate, improves readability, and helps prevent errors associated with explicit casting. By upgrading to Java 17 and utilizing this feature, you can write cleaner, safer, and more expressive code when dealing with type-specific operations on objects.

Switch Expressions

Java 17 finalizes the switch expressions feature, which was initially introduced as a preview feature in Java 12 and 13, and standardized in Java 14. This feature enhances the switch statement, making it more powerful and expressive.

New Syntax:

The new syntax allows switch to be used as an expression and introduces the arrow syntax for concise case labels:

int numLetters = switch (day) {
    case MONDAY, FRIDAY, SUNDAY -> 6;
    case TUESDAY -> 7;
    case THURSDAY, SATURDAY -> 8;
    case WEDNESDAY -> 9;
    default -> throw new IllegalArgumentException("Invalid day: " + day);
};

When to Use:

Use switch expressions when you want to:

  1. Assign the result of a switch directly to a variable
  2. Return a value from a switch
  3. Simplify multi-case labels
  4. Ensure exhaustive handling of all possible cases

Examples:

  1. Day of week description:

    String getDayType(DayOfWeek day) {
        return switch (day) {
            case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "Weekday";
            case SATURDAY, SUNDAY -> "Weekend";
        };
    }
    
  2. Complex calculations based on enum:

    enum Operation { PLUS, MINUS, MULTIPLY, DIVIDE }
    
    int calculate(Operation op, int x, int y) {
        return switch (op) {
            case PLUS -> x + y;
            case MINUS -> x - y;
            case MULTIPLY -> x * y;
            case DIVIDE -> {
                if (y == 0) {
                    throw new ArithmeticException("Division by zero");
                }
                yield x / y;
            }
        };
    }
    
  3. Pattern matching with switch expressions (preview feature in Java 17):

    static String formatValue(Object obj) {
        return switch (obj) {
            case Integer i -> String.format("int %d", i);
            case Long l -> String.format("long %d", l);
            case Double d -> String.format("double %f", d);
            case String s -> String.format("String %s", s);
            default -> obj.toString();
        };
    }
    

Migration from Java 8:

Here's an example of migrating a Java 8 switch statement to a Java 17 switch expression:

Java 8 version:

String getQuarterName(int month) {
    String quarter;
    switch (month) {
        case 1:
        case 2:
        case 3:
            quarter = "Q1";
            break;
        case 4:
        case 5:
        case 6:
            quarter = "Q2";
            break;
        case 7:
        case 8:
        case 9:
            quarter = "Q3";
            break;
        case 10:
        case 11:
        case 12:
            quarter = "Q4";
            break;
        default:
            throw new IllegalArgumentException("Invalid month: " + month);
    }
    return quarter;
}

Java 17 version:

String getQuarterName(int month) {
    return switch (month) {
        case 1, 2, 3 -> "Q1";
        case 4, 5, 6 -> "Q2";
        case 7, 8, 9 -> "Q3";
        case 10, 11, 12 -> "Q4";
        default -> throw new IllegalArgumentException("Invalid month: " + month);
    };
}

Switch expressions in Java 17 offer a more concise and expressive way to write switch statements. They reduce boilerplate, improve readability, and help prevent errors associated with fall-through behavior. By upgrading to Java 17 and utilizing this feature, you can write cleaner, safer, and more maintainable code when dealing with multi-way branching logic.

Text Blocks

Java 17 finalizes the Text Blocks feature, which was introduced as a preview feature in Java 13 and 14. Text Blocks provide a more convenient way to express multi-line strings in Java code.

New Syntax:

Text Blocks are defined using triple quotes (""") and can span multiple lines:

String textBlock = """
    This is a
    multi-line
    text block.""";

This replaces the traditional concatenation of string literals:

String oldStyle = "This is a\n" +
                  "multi-line\n" +
                  "string.";

When to Use:

Use Text Blocks when you want to:

  1. Improve readability of multi-line strings
  2. Preserve indentation and formatting
  3. Avoid escaping quotes in SQL queries or JSON literals
  4. Simplify HTML or XML content in Java code

Examples:

  1. SQL Query:

    String query = """
        SELECT id, name, email
        FROM users
        WHERE active = true
        ORDER BY name""";
    
  2. JSON:

    String json = """
        {
            "name": "John Doe",
            "age": 30,
            "city": "New York"
        }""";
    
  3. HTML:

    String html = """
        <html>
            <body>
                <h1>Hello, World!</h1>
            </body>
        </html>""";
    

Migration from Java 8:

Here's an example of migrating a multi-line string from Java 8 to Java 17 using Text Blocks:

Java 8:

String oldHtml = "<!DOCTYPE html>\n" +
                 "<html>\n" +
                 "    <head>\n" +
                 "        <title>Example</title>\n" +
                 "    </head>\n" +
                 "    <body>\n" +
                 "        <h1>Hello, World!</h1>\n" +
                 "    </body>\n" +
                 "</html>";

Java 17:

String newHtml = """
    <!DOCTYPE html>
    <html>
        <head>
            <title>Example</title>
        </head>
        <body>
            <h1>Hello, World!</h1>
        </body>
    </html>""";

Additional Features:

  1. Incidental white space trimming: The compiler automatically removes incidental white space, making indentation in your source code independent of the string's content.

  2. Trailing white space trimming: Blank spaces at the end of each line are removed, unless escaped with a backslash.

  3. String concatenation: Text Blocks can be concatenated with other strings or Text Blocks using the + operator.

  4. String formatting: You can use String.format() or formatted() method with Text Blocks.

    String name = "Alice";
    String message = """
        Hello, %s!
        Welcome to Java 17.""".formatted(name);
    

Text Blocks in Java 17 offer a more readable and maintainable way to work with multi-line strings. They reduce the need for escape characters, preserve formatting, and make code involving long string literals much cleaner. By upgrading to Java 17 and utilizing Text Blocks, you can significantly improve the readability and maintainability of your code, especially when dealing with SQL queries, JSON, HTML, or any other multi-line text content.

Local-Variable Type Inference (var)

Java 10 introduced local-variable type inference using the var keyword, which has been further refined and is now a stable feature in Java 17. This feature allows for more concise code by inferring the type of local variables from their initializers.

New Syntax:

Instead of explicitly declaring the type, you can use var:

var name = "John Doe";
var age = 30;
var list = new ArrayList<String>();

When to Use:

Use local-variable type inference when:

  1. The type is obvious from the right-hand side
  2. You want to reduce verbosity in your code
  3. You're working with complex generic types
  4. You're using anonymous classes or lambda expressions

Examples:

  1. Basic usage:

    // Java 8
    String name = "John Doe";
    int age = 30;
    List<String> names = new ArrayList<>();
    
    // Java 17
    var name = "John Doe";
    var age = 30;
    var names = new ArrayList<String>();
    
  2. With complex generic types:

    // Java 8
    Map<String, List<String>> map = new HashMap<String, List<String>>();
    
    // Java 17
    var map = new HashMap<String, List<String>>();
    
  3. In for-loops:

    // Java 8
    for (Map.Entry<String, List<String>> entry : map.entrySet()) {
        // ...
    }
    
    // Java 17
    for (var entry : map.entrySet()) {
        // ...
    }
    
  4. With lambda expressions:

    // Java 8
    Predicate<String> predicate = s -> s.length() > 5;
    
    // Java 17
    var predicate = (Predicate<String>) s -> s.length() > 5;
    
  5. With streams:

    // Java 8
    Stream<String> stream = list.stream().filter(s -> s.startsWith("A"));
    
    // Java 17
    var stream = list.stream().filter(s -> s.startsWith("A"));
    

Migration from Java 8:

When migrating from Java 8 to Java 17, you can gradually introduce var in your codebase. Here's an example of how you might refactor a Java 8 class:

Java 8:

public class UserService {
    public User findUser(String username) {
        DatabaseConnection connection = DatabaseConnection.getInstance();
        PreparedStatement statement = connection.prepareStatement("SELECT * FROM users WHERE username = ?");
        statement.setString(1, username);
        ResultSet resultSet = statement.executeQuery();
        
        if (resultSet.next()) {
            String name = resultSet.getString("name");
            int age = resultSet.getInt("age");
            return new User(username, name, age);
        }
        return null;
    }
}

Java 17:

public class UserService {
    public User findUser(String username) {
        var connection = DatabaseConnection.getInstance();
        var statement = connection.prepareStatement("SELECT * FROM users WHERE username = ?");
        statement.setString(1, username);
        var resultSet = statement.executeQuery();
        
        if (resultSet.next()) {
            var name = resultSet.getString("name");
            var age = resultSet.getInt("age");
            return new User(username, name, age);
        }
        return null;
    }
}

Local-variable type inference in Java 17 offers a way to write more concise code without sacrificing readability or type safety. It's particularly useful for reducing boilerplate when working with complex generic types or when the type is obvious from the context. By upgrading to Java 17 and judiciously using the var keyword, you can make your code cleaner and more maintainable while still leveraging Java's strong type system.

Follow me for more Java content

#java