Pattern Matching for switch case

Quick summary:

  • Upgrades switch statements/expressions to allow pattern matching.
  • Something you have to see to believe!
  • Released in Java 21 (2023), but introduced in Java 19 (2022).

Key Features

  • Deconstruction of Records: Enables pattern matching to automatically deconstruct records based on their components.
  • Simplified Syntax: Reduces the verbosity of extracting and checking record components, streamlining how developers handle complex data structures.
  • Enhanced Readability: Improves code clarity by allowing direct access to record components in instanceof expressions, making conditions easier to read and understand.
  • Integration with instanceof: Seamlessly integrates with existing instanceof checks, allowing for conditional actions based on record content in a type-safe manner.

Example

Before Java 21:

Before, it was technically possible to use a switch to suss out different types, but it was a very bad approach, and it involved manual casting (which can lead to class cast exceptions).

Object obj = // some object
switch (obj.getClass().getSimpleName()) {
    case "String":
        String s = (String) obj;
        System.out.println("String of length: " + s.length());
        break;
    case "Integer":
        Integer i = (Integer) obj;
        System.out.println("Integer value: " + i);
        break;
    default:
        System.out.println("Unknown type");
}

After Java 21:

With the new pattern matching, the switch automatically casts the object to that type and immediately makes its components available for local use.

Object obj = // some object
switch (obj) {
    case String s -> System.out.println("String of length: " + s.length());
    case Integer i -> System.out.println("Integer value: " + i);
    default -> System.out.println("Unknown type");
}

Notice how we don’t have to call the properties via the object, we have immediate access to those values.


👩‍💻 Hands-on Demo: Pattern Matching for switch case

  1. Create a BankAccount-class and a CheckingAccount-subclass and SavingsAccount-subclass (no implementation required).
  2. Create a BankApp-class and start writing in the main-method.
  3. Ask a user to enter the type of account they wanna create, and use a switch expression to immediately assign a new SavingsAccount-object or new CheckingAccount-object to a BankAccount variable.
  4. Use another switch case, this time with pattern matching to, based on the type of account, assign a different String-value to a variable named response, then print that response.

Solutions

🕵️‍♂️ Click here to reveal the solutions
public class BankApp {
    public static void main(String[] args) {
        Scanner keyboard = new Scanner(System.in);
        System.out.print("Enter the type of account you want to create: ");
        var account = switch (keyboard.nextLine()) {
            case "checking" -> new CheckingAccount();
            case "savings" -> new SavingsAccount();
            default -> new BankAccount();
        };
        String response = switch (account) {
            case CheckingAccount c -> "You have a checking account";
            case SavingsAccount s -> "You have a savings account";
            default -> "You have a bank account";
        };
        System.out.println(response);
    }
}
public class BankAccount {
}
public class CheckingAccount extends BankAccount {
}
public class SavingsAccount extends BankAccount {
}

Pattern Matching for switch case with Records

  • Records and pattern matching for switch case are a powerful combination!
  • We can now simplify complex logic massively.

Example

        switch (employee) {
            case FullTimeEmployee(String name, double salary) ->
                System.out.println(name + " is a full-time employee with a salary of $" + salary);
            case PartTimeEmployee(String name, double hourlyRate) ->
                System.out.println(name + " is a part-time employee earning $" + hourlyRate + " per hour");
            case Intern(String name, String school) ->
                System.out.println(name + " is an intern from " + school);
            default ->
                System.out.println("Unknown employee type");
        }
        double area = switch (shape) {
            case Circle(double radius) -> Math.PI * radius * radius;
            case Rectangle(double width, double height) -> width * height;
            case Triangle(double base, double height) -> 0.5 * base * height;
            default -> 0;
        };

You can even do fancy stuff like extra conditional checks at field level:

        switch (user) {
            case User(String name, "admin") ->
                System.out.println(name + " has all permissions.");
            case User(String name, "editor") ->
                System.out.println(name + " can create, edit, and delete their posts.");
            case User(String name, "viewer") ->
                System.out.println(name + " can only view content.");
            default ->
                System.out.println("Role is undefined.");
        }

This way of adding conditions to the pattern matching is known as a guarded pattern, and they let us get away with avoiding extra if-conditions in our switch cases.


👩‍💻 Hands-on Demo: Pattern Matching for switch case with Records

1. Refactor Shapes

  1. Refactor the following code to use a switch case instead:
        Shape[] shapes = new Shape[] {
                new Circle(5),
                new Rectangle(3, 4),
                new Rectangle(4, 3),
                new Circle(3),
        };

        for (Shape shape : shapes) {
            if(shape instanceof Circle(double radius)) {
                System.out.printf("Circle with radius %.1f", radius);
            } else if(shape instanceof Rectangle(double width, double height)) {
                System.out.printf("Rectangle with width %.1f and height %.1f", width, height);
            }
            System.out.printf(" and area %.1f and perimeter %.1f\n", shape.calcArea(), shape.calcArea());
        }

2. Expenses

  1. Create an Expense-interface and implementing records as shown in the UML (no extra logic required).
  2. In a ExpensesApp-class, write a calcExpense-method that accepts an Expense-object and, using a switch expression with record pattern matching, returns a double (the actual monetary value of that expense, which for each sub-record is different).
  3. In the main-method, make a collection of expenses, and, in one line (using streams), calculate the total expenses, then print it.

Solutions

🕵️‍♂️ Click here to reveal the solutions
1. Refactor Shapes
        Shape[] shapes = new Shape[]{
                new Circle(5),
                new Rectangle(3, 4),
                new Rectangle(4, 3),
                new Circle(3),
        };

        for (Shape shape : shapes) {
            String info = switch (shape) {
                case Circle(double radius) -> String.format("Circle with radius %.1f", radius);
                case Rectangle(double width, double height) ->
                        String.format("Rectangle with width %.1f and height %.1f", width, height);
                default -> "Unknown shape";
            };
            System.out.printf("%s and area %.1f and perimeter %.1f\n",
                    info, shape.calcArea(), shape.calcArea());
        }
2. Expenses
public class ExpenseApp {
    public static void main(String[] args) {
        double totalExpenses = Set.of(
                new Mortgage("My house", 1000, 0.05, 5),
                new OnlineSubscription("Netflix", "https://www.netflix.com", 10),
                new Product("iPhone", 1000),
                new Product("MacBook", 1000)
        ).stream().mapToDouble(ExpenseApp::calcExpense).sum();
        System.out.printf("Total expenses: $%,.2f\n", totalExpenses);
    }

    private static double calcExpense(Expense expense){
        return switch (expense) {
            case Mortgage(String propertyName, double monthlyPayment, double interestRate, int numberOfYears) -> monthlyPayment * numberOfYears * (1 + interestRate);
            case OnlineSubscription(String name, String url, double monthlyPrice) -> monthlyPrice;
            case Product(String name, double price) -> price;
            default -> 0;
        };
    }
}
public interface Expense {
}
public record Mortgage(String propertyName, double monthlyPayment, double interestRate, int numberOfYears) implements Expense {
}
public record OnlineSubscription(String name, String url, double monthlyPrice) implements Expense {
}
public record Product(String name, double price) implements Expense {
}

Pattern Matching with Sealed Classes

  • Sealed classes enhance Java’s type system, and when combined with pattern matching in switch-statements, they become a powerful tool for more expressive and safer code.
  • Pattern matching allows developers to handle objects differently based on their types, which is especially useful in hierarchies defined by sealed classes.

Example

public sealed class Payment permits CardPayment, BankTransfer {
    // Common payment methods
}

public void processPayment(Payment payment) {
    switch (payment) {
        case CreditCardPayment ccp -> System.out.println("Processing credit card payment");
        case BankTransfer bt -> System.out.println("Processing bank transfer");
        default -> System.out.println("Unknown payment type");
    }
}
  • In this example, Java’s pattern matching checks the type of Payment and executes a specific block of code for each type.
  • This use of pattern matching with sealed classes ensures that all possible subclasses are covered, and the compiler can even ensure that the switch statement is exhaustive, preventing runtime errors due to unhandled types.

Summary

  • Enhanced Pattern Matching: Java 21 introduces advanced pattern matching for switch, enabling automatic type casting and direct access to components, which simplifies how developers handle different data types.
  • Deconstruction of Records: The new feature allows for the seamless deconstruction of records within switch cases, providing a straightforward way to access record components without manual extraction.