Session 3 · Programming Technology

Java collections and generics

A bag (multiset) built twice – on an ArrayList and on a HashMap – to show how to choose a collection, why equals and hashCode matter, and how to read generic signatures, sort with a Comparator and use views like subList.

19 min readList vs Set vs MapGeneric types and methodsequals and hashCodeArrayList vs HashMap lookupComparator and stable sorting? super T and ? extends T

ArrayList, HashMap and HashSet are probably the three classes you will use most in your programming life. This session is about choosing between them and using them correctly: we build the same data structure – a bag (multiset) – twice, once on a list and once on a map, and compare what each costs. Along the way we learn to read the angle brackets that fill the JDK documentation: List<T>, <T> before a return type, ? super T.

The one idea to take away: choose the collection by the question your program asks most often. "What is the 3rd one?" is a List. "Is this one in there?" is a Set. "What belongs to this key?" is a Map. Picking the wrong one still works – it just makes every other line harder.

Pick the collection by the question you ask

The Java collections are a small family of interfaces with several implementations each (Fig. 1). The interface says what you can do – a List has positions, a Set has no duplicates, a Map pairs keys with values. The class says how it is stored, and therefore what is fast.

«interface»Collection<E>add(e) · remove(o)contains(o) · size()«interface»Map<K, V>put(k, v) · get(k)containsKey(k)Map is not a Collection:it stores key–value pairs«interface»List<E>get(i) · set(i, e)sort(cmp)«interface»Set<E>add(e): false if presentArrayList<E>array + sizeHashSet<E>hash table, no orderHashMap<K, V>hash table of entriesDeclare with the interface, create with the class:List<BagItem> data = new ArrayList<>();
Fig. 1 · The interfaces on top, the classes we use below. Map stands apart: it is not a Collection, it stores key–value pairs.
You mostly ask…UseFastSlow
"the i-th element", "in this order"ArrayListget(i), add at the endcontains, remove from the middle
"is x in there?", "no duplicates"HashSetadd, contains, removeno order, no positions
"what belongs to this key?"HashMapget, put, containsKey by keysearching by value

Declare variables, fields and parameters with the interface type, and name the class only after new. Then the rest of the code can only use what the interface promises, and swapping ArrayList for another List is a one-line change.

Bag.java – the field is a List, the object an ArrayList

// The variable has the interface type; only "new" names the class.
// Switching to another List later is a one-line change.
private final List<BagItem> data;

public Bag() {
    this.data = new ArrayList<>();   // diamond: <BagItem> is inferred
}

Generics: the type in angle brackets

Every collection is generic: the type in < > – the type parameter – says what it holds. From then on the compiler checks every use, so a wrong element type is a compile error instead of a crash at runtime, and you never cast what you take out.

A typed list

List<String> names = new ArrayList<>();
names.add("Anna");             // fine
names.add(42);                 // compile error: only Strings go in
String first = names.get(0);   // no cast: the compiler knows it's a String
NotationMeaning
List<String>a list of Strings
Map<String, Integer>two type parameters, in order: the key type and the value type
Map.Entry<String, Integer>one key–value pair of a map; Entry is declared inside Map, hence the dot
new ArrayList<>()the "diamond": the compiler fills in the type from the left-hand side
List<Integer>type parameters can't be primitives – no List<int>; the wrapper class is used and the compiler converts (autoboxing)
Lista "raw type" without brackets, left over from before generics – avoid it, the compiler checks nothing

You can write generic methods yourself. The numBoth example counts the common elements of two collections of any element type. The <T> before the return type is the declaration of the type variable – just as a variable must be declared before it is used. Collection<T> in the parameter list only uses it (Fig. 2).

declares T – like declaring a variablepublic static<T>intnumBoth(Collection<T> c1,Collection<T> c2)return typeuses T: both hold the same element typeAt the call site you don't write T – the compiler infers it from the arguments:numBoth(c1, c2) // List<Integer>T = IntegerLeave out the <T> and you get "cannot find symbol: class T".
Fig. 2 · <T> declares, Collection<T> uses. It sits before the return type because the return type itself may be T – as in <T> T first(List<T> list).

A generic class declares its type variable after the class name, and every instance method can use it. ReverseComparator<T> from the sorting example wraps any comparator and reverses it; T is decided when the object is created, new ReverseComparator<String>(…) or with the diamond.

ReverseComparator.java – a generic class

public class ReverseComparator<T> implements Comparator<T> {
    private final Comparator<? super T> original;

    public ReverseComparator(Comparator<? super T> original) {
        this.original = original;
    }

    @Override
    public int compare(T t1, T t2) {
        return original.compare(t2, t1);   // swap, don't negate
    }
}

A bag on an ArrayList

A bag is like a set, except that an element can be in it several times – so for each element we store how many copies there are. Operations: add some copies, remove some or all, ask how many, clear, and the set operations union, intersection and difference.

The first version keeps a List<BagItem>, where a BagItem is a small class holding the element and its count. To find an element we walk the list (Fig. 3).

bag : Bagfield: dataArrayList<BagItem>[0]"a"num = 21not "c"[1]"b"num = 33not "c"[2]"c"num = 32equals ✓howMany("c"): one equals() per element, from index 0Linear searchn different items →up to n equals() callsfor every add, removeand howMany.
Fig. 3 · Every add, remove and howMany starts with the same linear search. With three items that is nothing; with a hundred thousand it is the bottleneck.

Bag.java – linear search

private BagItem getItem(String item) {
    for (BagItem bi : data) {
        if (bi.getItem().equals(item)) {   // equals, not ==
            return bi;
        }
    }
    return null;                            // not in the bag
}

Bag.java – add: a new entry or a bigger count

public void add(String item, int num) {
    Objects.requireNonNull(item, "item must not be null");
    if (num <= 0) {
        throw new IllegalArgumentException("num must be positive: " + num);
    }
    BagItem bi = getItem(item);
    if (bi == null) {
        data.add(new BagItem(item, num));   // new entry
    } else {
        bi.addNum(num);                      // existing entry: only the count grows
    }
}

Two decisions in add are worth copying. The method refuses bad arguments immediately with an exception, so the bag can never get into an invalid state – a negative count, a null element. And it keeps an invariant: an entry with count 0 never stays in the list; remove deletes the entry instead. Every other method can rely on that.

The collections' search methods – contains, indexOf, remove(Object) – all call equals. The version inherited from Object is plain ==, so for your own classes you override it. And whoever overrides equals must override hashCode too: equal objects must have equal hash codes, otherwise HashSet and HashMap look in the wrong bucket and don't find them.

BagItem.java – equals and hashCode together

@Override
public boolean equals(Object o) {
    if (this == o) {
        return true;
    }
    if (!(o instanceof BagItem)) {       // also false for null
        return false;
    }
    BagItem other = (BagItem) o;
    return Objects.equals(item, other.item) && num == other.num;
}

@Override
public int hashCode() {                  // equal objects – equal hash codes
    return Objects.hash(item, num);
}

The same bag on a HashMap

The bag really is a mapping from element to count, so a Map<String, Integer> fits it exactly – no BagItem class needed. More importantly, the lookup is no longer a walk: the key's hash code points at one bucket, and only the few entries in it are compared (Fig. 4).

howMany("c")"c".hashCode()→ bucket 3buckets01"a" → 2123"c" → 324"b" → 335Hash lookupone hashCode(), one bucket,equals() only on the fewentries in that bucket –same cost for 3 or 3 million.
Fig. 4 · A HashMap finds a key in (on average) constant time, independent of its size – the reason to prefer it whenever you look things up by key.

Bag.java – add and howMany on a map

private final Map<String, Integer> data = new HashMap<>();

public void add(String item, int num) {
    // put overwrites the old value, so compute the new count first
    data.put(item, howMany(item) + num);
    // the same in one call: data.merge(item, num, Integer::sum);
}

public int howMany(String item) {
    return data.getOrDefault(item, 0);   // get would return null for a missing key
}

put overwrites the value of an existing key – the map can't know you meant "add to it". So we compute the new count first, or let merge do it in one call. To walk a map, iterate over entrySet(), which gives you key and value together; walking keySet() and calling get for every key does each lookup twice.

ArrayList bagHashMap bag
find an elementlinear: up to n equals() callsconstant on average: one hash, one bucket
extra classBagItem (element + count)none – the map entry is the pair
orderinsertion orderno guaranteed order
equals/hashCode needed forBagItem (for list search)the key type – String has them

Union, intersection, difference

On bags the set operations work element by element on the counts (Fig. 5): the union adds them, the intersection takes the smaller one, the difference subtracts – and whatever drops to zero or below is left out.

itembagbag2union: a + bintersection: mindifference: a − ba21426321left out21 − 42 < 0b33–33left outmin = 033c3212441220Every operation returns a new Bag; neither input changes. The difference is not symmetric: bag2 − bag = {a=21}.
Fig. 5 · The three operations on the example bags of the session. Each one builds a new Bag and leaves both inputs unchanged.

Bag.java – union on the HashMap version

public Bag union(Bag other) {
    Bag result = new Bag();
    for (Map.Entry<String, Integer> entry : other.data.entrySet()) {
        result.add(entry.getKey(), entry.getValue());
    }
    for (Map.Entry<String, Integer> entry : data.entrySet()) {
        result.add(entry.getKey(), entry.getValue());
    }
    return result;
}

Notice that union reads other.data although data is private. Visibility in Java is per class, not per object: a Bag may see the private fields of another Bag. And the result is built through add, so it gets the validation and the zero-count invariant for free. Intersection and difference are your first tasks below.

Bulk operations instead of loops

Collections have operations that work on a whole collection at once: addAll (union), retainAll (intersection), removeAll (difference), containsAll. The numBoth example uses them to count the common elements of two collections without writing a single loop – the loops still run, inside the library (Fig. 6).

c1 – a List, unchanged[1, 2, 2, 3, 3, 4]new HashSet<>(c1)common{1, 2, 3, 4}c2 – a List, unchanged[2, 3, 3, 7, 8]new HashSet<>(c2)s2{2, 3, 7, 8}common.retainAll(s2){2, 3} → size() = 2Turning the lists into sets drops the duplicates, so multiplicity does not count.retainAll changes the set it is called on – that is why it runs on a copy, never on the caller's collection.
Fig. 6 · Sets do two jobs here: they drop the duplicates, and they make retainAll fast, because retainAll calls contains on its argument for every element.

NumBoth.java – intersection without a loop

public static <T> int numBoth(Collection<T> c1, Collection<T> c2) {
    Set<T> common = new HashSet<>(c1);   // copy: duplicates drop out
    Set<T> s2 = new HashSet<>(c2);       // fast contains() for retainAll
    common.retainAll(s2);                // intersection, in place on the copy
    return common.size();
}

Sorting with a Comparator

Some classes have a natural order: they implement Comparable and compare themselves – numbers by size, Strings alphabetically. For every other order you hand the sort a Comparator: an object with one method, compare(a, b), that returns a negative number if a comes first, zero if they are equal, positive if b comes first. The sort algorithm itself knows nothing about your order – it only keeps asking (Fig. 7).

Natural order vs a Comparator

// Natural order: the class compares itself (String, Integer implement Comparable)
Collections.sort(words);

// Any other order: pass a Comparator
words.sort(String.CASE_INSENSITIVE_ORDER);
creatures.sort(Comparator.comparingInt(Creature::getDistance).reversed());

The Sort1 example sorts integers by their remainder modulo n, ascending or descending:

RemainderComparator.java – the compare method

public class RemainderComparator implements Comparator<Integer> {
    private final int n;
    private final boolean ascending;

    @Override
    public int compare(Integer i1, Integer i2) {
        int r1 = Math.floorMod(i1, n);   // 0..n-1 even for negative numbers
        int r2 = Math.floorMod(i2, n);
        return ascending ? Integer.compare(r1, r2) : Integer.compare(r2, r1);
    }
}
list.sort(cmp)the algorithm: moves elementsRemainderComparator(3, true)compare(a, b) → intcompare(10, 21)?positive: 21 goes firstnegative → a first · zero → keep their order · positive → b firstResult for [1, 2, 3, 4, 10, 21, 12, 34, 5, -1, -5], ascending:remainder 032112remainder 1141034-5remainder 225-1-5 is in group 1 because Math.floorMod(-5, 3) = 1; the % operator would give -2.Inside a group the original order survives (1, 4, 10, 34, -5): List.sort is stable.
Fig. 7 · list.sort moves the elements; the comparator only answers which of two comes first. Change the comparator and the same algorithm produces a different order.

Three details in this small class are worth knowing. Math.floorMod instead of %, because % keeps the sign (-1 % 3 == -1), while the mathematical remainder is always between 0 and n-1. Integer.compare(r1, r2) instead of the tempting r1 - r2, which overflows for large numbers of opposite sign. And descending order by swapping the two arguments – negating the result fails for Integer.MIN_VALUE, whose negation is itself.

Sort1.java – sort a copy; an overload instead of a default argument

public static List<Integer> getSortedByNRemainder(List<Integer> list, int n) {
    return getSortedByNRemainder(list, n, true);   // the "default argument"
}

public static List<Integer> getSortedByNRemainder(List<Integer> list, int n, boolean ascending) {
    List<Integer> listCopy = new ArrayList<>(list);   // the caller's list stays as it was
    listCopy.sort(new RemainderComparator(n, ascending));
    return listCopy;
}

The function sorts a copy, so the caller's list is not touched – the signature returns a list, and a reader expects a function that returns something to leave its input alone. Java has no default parameter values; the usual substitute is an overload with fewer parameters that calls the full version, so the logic stays in one place.

Reading ? super T and ? extends T

The Sort2 example has this signature, and the JDK documentation is full of similar ones:

public static <T> void sort(List<T> list, int i, int j, Comparator<? super T> comparator)

? is a wildcard – "some type I don't name" – and super T restricts it to T or a supertype of T. Together: a comparator that can compare Ts, or something more general. If we sort a List<Integer> (so T = Integer, whose supertypes are Number and Object):

Comparator passed inAccepted?Why
Comparator<Integer>yesexactly T
Comparator<Number>yeswhoever can compare any two numbers can compare two Integers
Comparator<Object>yeswhoever can compare any two objects can compare two Integers (TextLengthComparator)
Comparator<String>noString is not a supertype of Integer

Why not simply Comparator<T>? Because inheritance does not carry over to type parameters: Integer is a Number, but a List<Integer> is not a List<Number>. Surprising at first, but necessary:

List<Integer> ints = new ArrayList<>();
List<Number> nums = ints;   // compile error – luckily, because otherwise...
nums.add(3.14);             // ...a double would end up in the list of ints

The wildcards loosen this rigidity exactly where it is safe. The rule of thumb: something you take Ts out of (a source) is ? extends T; something you give Ts to (a consumer) is ? super T. A comparator receives two Ts, so it is a consumer.

Two signatures from the JDK

// Collections.sort: T must be able to compare itself with a T (or a supertype of T)
static <T extends Comparable<? super T>> void sort(List<T> list)

// ArrayList copy constructor: takes anything whose elements ARE E's
public ArrayList(Collection<? extends E> c)

<T extends Comparable<? super T>> is a bounded type variable: T can't be anything, only a type that can compare itself to another T. That is why this version of sort needs no comparator.

Views: subList and keySet

Some methods don't return a new collection but a view of an existing one. list.subList(from, to) is a window on a slice of the list (from inclusive, to exclusive): sort the view and you have sorted that slice of the original. The Sort2 example uses exactly that to sort three slices of one list in place (Fig. 8).

05122831495367beforesubList(0, 2)sort(reversed)subList(2, 5)sort(comparator)subList(5, 7)sort(reversed)5218973afterThe views share the original list's storage: sorting a view sorts that slice of the original, in place.That is why the method returns void – the caller's list itself is the result.
Fig. 8 · Three views over one list. Nothing is copied back, because nothing was copied in the first place.

keySet(), values() and entrySet() of a map are views too: removing a key from the key set removes the entry from the map. That is useful inside a class – and dangerous when a getter hands a view out.

Bag.java – return a copy, not the view

public List<String> items() {
    // keySet() is a view: removing from it removes from the map.
    // Hand out a copy, so the caller can't reach the bag's insides.
    return new ArrayList<>(data.keySet());
}

Practice

Task 1

Intersection and difference on the ArrayList bag

Add intersection(Bag other) and difference(Bag other) to the ArrayList-based bag. Both return a new bag and change neither input. Check your result against Fig. 5, and check that bag.difference(bag2) and bag2.difference(bag) differ.

Approach

Walk this bag's items only. For each, ask the other bag how many it has – howMany returns 0 for a missing element, which is exactly what both formulas need. Add to the result only when the count is positive (your own add would refuse 0 anyway).

for (BagItem bi : data) {
    int common = Math.min(bi.getNum(), other.howMany(bi.getItem()));
    // ... only add to the result if common is positive
}
Why not loop over the other bag too?

An element that is only in other can't be in the intersection, and can't be in this − other either. So the other bag never needs to be walked.

Task 2

Intersection and difference on the HashMap bag

Implement the same two operations on the HashMap-based bag. Then compare the two versions: how many equals calls does each make for two bags of 10,000 different items?

Approach

Iterate over entrySet(), and get the other count with a lookup – a nested loop over the other map would throw away the one advantage of the hash map.

Bag result = new Bag();
for (Map.Entry<String, Integer> entry : data.entrySet()) {
    int mine = entry.getValue();
    int theirs = other.howMany(entry.getKey());   // a lookup, not a second loop
    // ... your rule for the count, and add it if it's positive
}
return result;

Task 3

Sort a slice one way and the rest the other way

Write sort(list, i, j, comparator) that changes the list in place: the elements from index i to j (both inclusive) are sorted with the comparator, the part before and the part after are each sorted in the opposite direction. With natural order, i = 2, j = 4:

before:  [5, 2 | 8, 1, 9 | 3, 7]
after:   [5, 2 | 1, 8, 9 | 7, 3]

Make it generic, so it works for List<Integer> with Comparator.naturalOrder() and for List<String> with String.CASE_INSENSITIVE_ORDER. Reject bad indexes before touching the list.

Approach

You need a reversed comparator – write the small generic ReverseComparator<T> from the generics section (or use comparator.reversed()). Then sort three subList views; nothing has to be copied back. Mind that subList's upper bound is exclusive.

Comparator<T> reversed = new ReverseComparator<>(comparator);
list.subList(i, j + 1).sort(comparator);   // to is exclusive, hence j + 1
// ... and the two outer slices with reversed
Checking the indexes

The valid range is 0 <= i <= j < list.size(). If you only let subList throw, the first slice may already be sorted when the second one fails – and the caller gets a half-modified list together with the exception.

Task 4

Count the words of a text

Read words from the user until an empty line, then print each different word with the number of times it occurred, most frequent first. Words that differ only in letter case count as the same word.

Approach

This is the bag again, with a Map<String, Integer>: lowercase each word and count it. For the output, copy entrySet() into a list and sort it by value, descending.

Map<String, Integer> counts = new HashMap<>();
for (String word : words) {
    counts.merge(word, 1, Integer::sum);
}

Summary

  • Choose by the question you ask: positions and order – List; "is it in there?" – Set; lookup by key – Map. Declare with the interface, create with the class.
  • Generics move type errors to compile time. <T> before the return type declares a type variable; type parameters are never primitives.
  • Collections find objects with equals, hash collections also with hashCode – override both together, and keep keys immutable.
  • An ArrayList search is linear; a HashMap lookup is constant on average. Iterate a map with entrySet(); use getOrDefault where get could return null.
  • Sort with a Comparator: the algorithm moves, the comparator decides. Use Integer.compare, not subtraction.
  • ? extends T for sources, ? super T for consumers. subList and keySet are views – return copies from your getters.