jario_
Back to overview

Chapter 12

Generics and modern Java

You already know ArrayList<String>: the list knows that only strings go in. In this chapter you write such generic classes and methods yourself. On top of that come tools from newer Java versions that make code shorter and safer: var, pattern matching, Optional and sealed.

var and instanceof

With var you leave out the type of a local variable. Java infers it from the value right of the =: var names = new ArrayList<String>(); has the type ArrayList<String>. The type is just as fixed as before, it's just not written out. With var, the type goes in the angle brackets on the right. var also works in a for-each loop, but not for fields and parameters.

A variable of type Object can hold anything. instanceof checks what's inside and gives you a matching variable right away: in if (value instanceof String s), s is a String inside the if block, without a cast.

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static String describe(Object value) {
        if (value instanceof String s) {
            return "Text with " + s.length() + " characters";
        } else if (value instanceof Integer i) {
            return "Integer, doubled " + (i * 2);
        }
        return "Something else";
    }

    public static void main(String[] args) {
        var names = new ArrayList<String>();
        names.add("Ada");
        names.add("Linus");
        for (var name : names) {
            System.out.println(name.toUpperCase());
        }
        var count = names.size();
        System.out.println("Count: " + count);

        List<Object> things = List.of("hello", 21, 3.5);
        for (var thing : things) {
            System.out.println(describe(thing));
        }
    }
}

Generic classes and methods

class Box<T> has a type parameter T. Inside the class, you use T like a normal type. Only when you create a box is it decided what T is: in Box<String> it's String, in Box<Integer> it's Integer. That way you write the box once, and get() still returns the right type. Records can have type parameters too, even several: record Pair<A, B>(A first, B second).

A single method can also have a type parameter. It goes before the return type:

public static <T extends Comparable<T>> T smallest(List<T> list)

T extends Comparable<T> means: T must be comparable. Only then may you call compareTo in the method. The method works with numbers, strings and any class of your own that implements Comparable.

import java.util.List;

class Box<T> {
    private T content;

    public void put(T content) {
        this.content = content;
    }

    public T get() {
        return content;
    }
}

record Pair<A, B>(A first, B second) {
}

public class Main {
    public static <T extends Comparable<T>> T smallest(List<T> list) {
        T best = list.get(0);
        for (T element : list) {
            if (element.compareTo(best) < 0) {
                best = element;
            }
        }
        return best;
    }

    public static void main(String[] args) {
        Box<String> word = new Box<>();
        word.put("Java");
        String w = word.get();
        System.out.println(w.length());

        Box<Integer> number = new Box<>();
        number.put(7);
        int n = number.get();
        System.out.println(n + 1);

        Pair<String, Integer> entry = new Pair<>("Ada", 36);
        System.out.println(entry);

        System.out.println(smallest(List.of(5, 2, 9)));
        System.out.println(smallest(List.of("pear", "apple", "fig")));
    }
}

Optional

What should a search return when it finds nothing? It's easy to forget to check for null. An Optional<T> makes it visible in the type that there might be nothing. It either contains a value (Optional.of(x)) or is empty (Optional.empty()).

  • isPresent() checks whether there's a value
  • orElse(fallback) returns the value or the fallback
  • map(...) transforms the content if there is one; empty stays empty
import java.util.List;
import java.util.Optional;

public class Main {
    public static Optional<String> firstStartingWith(List<String> words, char letter) {
        for (String w : words) {
            if (w.charAt(0) == letter) {
                return Optional.of(w);
            }
        }
        return Optional.empty();
    }

    public static void main(String[] args) {
        List<String> fruits = List.of("apple", "banana", "cherry");
        Optional<String> b = firstStartingWith(fruits, 'b');
        Optional<String> z = firstStartingWith(fruits, 'z');

        System.out.println(b.isPresent());
        System.out.println(b.orElse("none"));
        System.out.println(z.orElse("none"));
        System.out.println(b.map(String::length).orElse(0));
        System.out.println(z.map(String::length).orElse(0));
    }
}

sealed and switch with patterns

sealed interface Payment permits Cash, Card, Voucher states: there are exactly these three kinds of Payment, no others. Then a switch can decide directly by kind:

  • case Voucher v -> checks the kind and gives you the object as v
  • case Cash(double amount) -> unpacks a record right away (record pattern)
  • when adds a condition: case Cash(double amount) when amount >= 100 ->

Because the compiler knows all permitted kinds, the switch needs no default. If a case is missing, it reports it. As with else if, the first matching case wins. So the more specific one with when must come first.

sealed interface Payment permits Cash, Card, Voucher {
}

record Cash(double amount) implements Payment {
}

record Card(double amount, String bank) implements Payment {
}

record Voucher(String code) implements Payment {
}

public class Main {
    public static String describe(Payment p) {
        return switch (p) {
            case Cash(double amount) when amount >= 100 -> "Large cash payment: " + amount;
            case Cash(double amount) -> "Cash: " + amount;
            case Card(double amount, String bank) -> "Card from " + bank + ": " + amount;
            case Voucher v -> "Voucher " + v.code();
        };
    }

    public static void main(String[] args) {
        System.out.println(describe(new Cash(20)));
        System.out.println(describe(new Cash(150)));
        System.out.println(describe(new Card(35.5, "Sparkasse")));
        System.out.println(describe(new Voucher("SUMMER10")));
    }
}

Common mistakes

  • var without a value

    The compiler rejects var count; or var x = null; with cannot infer type, because there's nothing on the right to read the type from.

  • Box<int>

    Your own generic classes also only take classes: Box<Integer>, not Box<int>. You know this from List<Integer>.

  • compareTo without a bound

    If there's only <T> before the method, Java knows nothing about T. element.compareTo(best) then reports cannot find symbol. Only <T extends Comparable<T>> allows the call.

  • General case before the specific one

    If case Cash c -> comes before case Cash(double amount) when amount >= 100 ->, the second case is never reached. The compiler reports this case label is dominated by a preceding case label.