Course outline · 0% complete

0/29 lessons0%

Course overview →

Writing generic code

lesson 8-1 · ~6 min · 22/29

From lesson 7-1, the <String> in ArrayList<String> buys you a compiler that rejects adding anything which is not a String.

The type parameter makes the list type-safe at compile time, so tasks.add(42) will not compile, and everything get() hands back is already a String with no casting needed.

In this lesson you write classes with type parameters of your own, which is the same mechanism seen from the inside.

Type parameters

Before Java 5, collections held plain Object values. You could put a String into a list of what you thought were Integers and nothing complained, until some distant line cast it back and crashed at runtime, in production, far from the actual mistake.

Generics were added to move that entire class of bug to compile time, and they are why ArrayList<String> can promise what it holds.

Suppose you write a Box class that holds one item. Without generics you would need a StringBox, an IntBox, a DogBox, and copies everywhere. A type parameter lets you write it once with a placeholder:

class Box<T> {
  private T item;

  void put(T item) {
    this.item = item;
  }

  T get() {
    return item;
  }
}

T is a type parameter, a stand-in filled at use time. Box<String> makes every T in the class mean String, and Box<Integer> makes it Integer.

This is exactly how ArrayList<E> and HashMap<K, V> are written in the standard library, so reading their source is now within reach. The compiler enforces each choice, and a Box<String> will not accept an Integer.

One class, two element types

The same Box used for Strings and for Integers.

public class Main {
  public static void main(String[] args) {
    Box<String> stringBox = new Box<>();
    System.out.println(stringBox.isEmpty());
    stringBox.put("hello");
    System.out.println(stringBox.get());
    System.out.println(stringBox.isEmpty());

    Box<Integer> intBox = new Box<>();
    intBox.put(7);
    System.out.println(intBox.get() + 1);
  }
}

class Box<T> {
  private T item;

  void put(T item) {
    this.item = item;
  }

  T get() {
    return item;
  }

  boolean isEmpty() {
    return item == null;
  }
}

Output

true
hello
false
8

A fresh box is empty because the field starts at null, which is the object-type default from lesson 5-1.

The last line does arithmetic on intBox.get() with no cast, since the compiler already knows the result is an Integer and autoboxing unwraps it. That absence of casting is what generics bought.

Generic methods

A single method can take a type parameter of its own, declared in angle brackets before the return type:

static <T> void printAll(ArrayList<T> items) {
  for (T item : items) {
    System.out.println(item);
  }
}

The <T> before void says this method introduces its own placeholder, which is filled from the argument at each call site. printAll(names) and printAll(scores) both work with no overloads.

The method can also constrain what T may be:

static <T extends Number> double sum(ArrayList<T> nums) { ... }

T extends Number is a bound, promising that whatever T turns out to be, it has Number's methods. Without a bound, a generic method can rely only on the methods every object has, such as toString.

A type parameter under test

With stringBox declared as a Box<String>, the call stringBox.put(42) does not compile.

Fixing T to String makes put demand a String, so the mistake is caught before the program runs. That is the entire point of generics.

Eraput(42) on a box of Strings
pre-generics, holding Objectaccepted, crashes later at the cast
with Box<String>rejected by the compiler

The pre-generics alternative accepted anything and only exploded at the cast, possibly minutes of runtime away from the line that was actually wrong. Moving the failure to compile time also moves it next to its cause.

A generic class with two parameters

Pair<A, B> holds two values of independently chosen types.

public class Main {
  public static void main(String[] args) {
    Pair<String, Integer> p1 = new Pair<>("age", 30);
    Pair<Integer, Integer> p2 = new Pair<>(1, 2);
    System.out.println(p1);
    System.out.println(p2);
  }
}

class Pair<A, B> {
  private A first;
  private B second;

  Pair(A first, B second) {
    this.first = first;
    this.second = second;
  }

  @Override
  public String toString() {
    return "(" + first + ", " + second + ")";
  }
}

Output

(age, 30)
(1, 2)

Two type parameters are declared together as class Pair<A, B>, and the fields use them like ordinary types. The constructor mirrors lesson 5-2 exactly, with this.first = first resolving the shadowing.

toString from lesson 5-3 joins both values with +, which works whatever A and B are, because every object has a toString. The second pair uses Integer for both parameters, showing that the two placeholders are independent rather than required to differ.