Lists, Maps, Sets and the Collections Framework

Article · 13:00

Three interfaces carry almost every Java program: List for order, Set for membership, Map for lookup. You program against the interface and choose the implementation once - and if your keys are your own type, the hashCode contract is not optional.

Lists, Maps, Sets and the Collections Framework

Three interfaces carry almost every Java program: List for order, Set for membership, Map for lookup. You program against the interface and choose the implementation once, at the point you create it.

import java.util.*;

public class Collections1 {
    public static void main(String[] args) {
        List<String> tasks = new ArrayList<>();       // the type on the left is
        Set<String> tags = new HashSet<>();           // the interface, on purpose
        Map<String, Integer> stock = new HashMap<>();

        tasks.add("write");
        tasks.add("review");
        tasks.add("write");                           // a List keeps duplicates

        tags.add("java");
        tags.add("basics");
        tags.add("java");                             // a Set does not

        stock.put("apples", 3);
        stock.put("pears", 0);

        System.out.println(tasks);
        System.out.println(tags.size());
        System.out.println(stock);

        // The factory methods build IMMUTABLE collections, which is what you
        // want for anything that should not change after construction.
        List<String> fixed = List.of("a", "b", "c");
        try {
            fixed.add("d");
        } catch (UnsupportedOperationException e) {
            System.out.println("List.of is immutable");
        }

        // Wrap one in a mutable copy when you do need to add to it.
        List<String> copy = new ArrayList<>(fixed);
        copy.add("d");
        System.out.println(copy);
    }
}

Which List, and which Map

ArrayList is an array that grows: indexing is instant, inserting in the middle shifts everything after it. LinkedList is nodes: inserting where you already are is instant, indexing walks the chain. In practice ArrayList is almost always the right answer, because walking a chain of nodes scattered through memory is slower than the copy you were trying to avoid.

import java.util.*;

public class WhichOne {
    public static void main(String[] args) {
        Map<String, Integer> hash = new HashMap<>();      // no order at all
        Map<String, Integer> linked = new LinkedHashMap<>();   // insertion order
        Map<String, Integer> sorted = new TreeMap<>();    // sorted by key

        for (var map : List.of(hash, linked, sorted)) {
            map.put("pears", 2);
            map.put("apples", 5);
            map.put("cherries", 9);
        }

        System.out.println("HashMap:       " + hash.keySet());
        System.out.println("LinkedHashMap: " + linked.keySet());
        System.out.println("TreeMap:       " + sorted.keySet());

        // A HashMap's iteration order is not defined and not stable across
        // versions. Depending on it is a bug waiting for an upgrade.
    }
}

Reading a Map without tripping over null

import java.util.*;

public class Maps {
    public static void main(String[] args) {
        Map<String, Integer> stock = new HashMap<>(Map.of("apples", 3, "pears", 0));

        System.out.println(stock.get("apples"));
        System.out.println(stock.get("plums"));                 // null, not 0
        System.out.println(stock.getOrDefault("plums", 0));      // the safe read

        stock.putIfAbsent("apples", 99);                         // no change
        stock.merge("apples", 1, Integer::sum);                  // add to existing
        stock.computeIfAbsent("plums", key -> 0);                // insert a default

        System.out.println(stock);

        for (Map.Entry<String, Integer> entry : stock.entrySet()) {
            System.out.println(entry.getKey() + " -> " + entry.getValue());
        }

        // forEach takes the key and the value, which reads better still.
        stock.forEach((key, value) -> System.out.println(key + ": " + value));
    }
}

getOrDefault, merge and computeIfAbsent replace the read-check-write sequences that most NullPointerExceptions in Java come from.

Anything used as a HashMap key must obey the contract

A HashMap finds an entry by hash first and equals second. An object whose hashCode disagrees with its equals - or whose fields change after it is used as a key - becomes unfindable.

import java.util.*;

public class Keys {
    // A record gets equals and hashCode from its components, which is why it is
    // the right shape for a key.
    record Point(int x, int y) { }

    static final class Mutable {
        int x;
        Mutable(int x) { this.x = x; }
        @Override public boolean equals(Object o) {
            return o instanceof Mutable m && m.x == x;
        }
        @Override public int hashCode() { return Integer.hashCode(x); }
    }

    public static void main(String[] args) {
        Map<Point, String> byPoint = new HashMap<>();
        byPoint.put(new Point(1, 2), "origin-ish");
        System.out.println(byPoint.get(new Point(1, 2)));   // found: equal records

        Map<Mutable, String> byMutable = new HashMap<>();
        var key = new Mutable(1);
        byMutable.put(key, "stored");
        key.x = 2;                                          // the hash just moved
        System.out.println(byMutable.get(key));             // null - lost in the map
        System.out.println(byMutable.size());               // but still in there
    }
}

Sorting

import java.util.*;

public class Sorting {
    record Learner(String name, int score) { }

    public static void main(String[] args) {
        List<Learner> learners = new ArrayList<>(List.of(
                new Learner("Ada", 95),
                new Learner("Grace", 88),
                new Learner("Alan", 95)));

        learners.sort(Comparator.comparingInt(Learner::score));
        System.out.println(learners);

        // Descending by score, then by name for the ties - which is the usual
        // shape of a real sort, and reads like the sentence you would say.
        learners.sort(Comparator.comparingInt(Learner::score).reversed()
                .thenComparing(Learner::name));
        System.out.println(learners);

        // sort() mutates the list. To keep the original, copy first.
        List<Learner> byName = new ArrayList<>(learners);
        byName.sort(Comparator.comparing(Learner::name));
        System.out.println(byName.getFirst().name());   // Java 21: getFirst()
    }
}

What to take away

  • Declare the interface (List, Map, Set), choose the implementation once.
  • List.of, Map.of and Set.of are immutable - copy into an ArrayList to add.
  • ArrayList unless you can say why not; HashMap unless you need an order.
  • getOrDefault, merge and computeIfAbsent remove most null handling.
  • A key's hashCode must agree with its equals, and must not change afterwards.
  • Comparator.comparing(...).thenComparing(...) reads like the sort you meant.