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.ofandSet.ofare immutable - copy into anArrayListto add.ArrayListunless you can say why not;HashMapunless you need an order.getOrDefault,mergeandcomputeIfAbsentremove most null handling.- A key's
hashCodemust agree with itsequals, and must not change afterwards. Comparator.comparing(...).thenComparing(...)reads like the sort you meant.