Layers of Logic

10.1

The Collections Framework

Each way of storing data makes some jobs quick and others costly. Java gives you several choices behind shared interfaces, so you can switch when your program needs something different.

Core19 min read4 exercises
01

Previously on

You already know the parts that make this framework work.

In Section 4.2 you learned why arrays provide fast access but have a fixed length. In its last exercise you built a growing array by hand and found that doubling its size beats adding one slot at a time.

Section 7.2 gave you interfaces and polymorphism. Section 9.3 gave you generics.

This phase puts those ideas together.

02

The problem

Imagine that your program needs to keep some numbers and answer questions about them.

int[] numbers = {3, 7, 10, 1, 4, 12};

Adding to the end is quick. Put the value in the next free slot. The work stays the same even if the array is large.

Finding the largest takes time. The largest value could be anywhere, so you must inspect every element. A million elements can mean a million checks.

So sort it and keep it sorted:

int[] sorted = {1, 3, 4, 7, 10, 12};

Now the largest value is easy to find. It is the last element: sorted[sorted.length - 1].

Adding has become more expensive. To insert 6, you first find its place, then shift every following value one slot to the right.

Sorting made one job easier and another job harder.

03

The idea

Java handles this by providing several implementations: array-based, linked, hash-based, and tree-based. Each one has different strengths. They share interfaces, which means you can often change the implementation without changing the rest of your code.

List<Integer> numbers = new ArrayList<>();
numbers.add(1);
numbers.get(0);

numbers = new LinkedList<>(); // the implementation changed
numbers.add(1);               // the same List methods still compile
numbers.get(0);

The variable has the type List. At first it refers to an ArrayList; later it refers to a LinkedList. The methods here come from List, so both objects can handle them.

This is polymorphism from Section 7.2. The compiler checks that you are using List methods. When the program runs, Java uses the methods from the object that is actually there.

The shape of the framework

                Iterable
                   |
               Collection
        ___________|___________
       |           |           |
     List         Set        Queue

  ArrayList    HashSet     ArrayDeque
  LinkedList   TreeSet     PriorityQueue
  Vector       LinkedHashSet
  Stack


                  Map           <- a separate family, not a Collection
        ___________|___________
       |           |           |
    HashMap    TreeMap    LinkedHashMap

Read the diagram from top to bottom. Each interface adds a new promise:

InterfaceWhat it promises
Iterablethe starting pointyou can visit one item at a time
Collectionadds common operationsadd, remove, size, contains
Listadds order and positionsget(3), duplicates allowed
Setrequires unique itemsno duplicates, usually no order
Queuedefines an order for removalwhich item comes out next
Mapis not a Collectionstores key-value pairs
04

Under the hood

Core

Why Map sits outside

This often surprises people at first.

Collection is designed for one item at a time. Its central method is:

boolean add(E element);

A Map needs a pair for each entry: a key and a value. Its central method is:

V put(K key, V value);

put needs two arguments, so it does not fit the one-element add(E element) method. That is why Map has its own branch of the framework.

You can check it yourself:

System.out.println(Collection.class.isAssignableFrom(Map.class));   // false

HashMap implements Map, Cloneable, and Serializable. It does not implement Iterable, which is why this does not compile:

for (String s : myMap) { }     // ERROR. A Map is not Iterable.

To loop over a map, first ask it for a collection view:

for (String key : myMap.keySet()) { }
for (Integer v  : myMap.values()) { }
for (Map.Entry<String, Integer> e : myMap.entrySet()) { }

Those three results are collections. They let you work through a map one key, value, or entry at a time.

What each implementation is really made of

The framework is built from structures you already know, then presented through shared interfaces.

ClassWhat is underneath
ArrayLista growable arraythe same idea you built in the 4.2 exercises
LinkedListnodes joined by referenceseach node points to the next one
HashSeta HashMap that keeps the keysbuckets chosen with hashCode
TreeSeta balanced treeitems stay sorted as the tree is searched
ArrayDequea circular arrayefficient at both ends
PriorityQueuea heap stored in an arraythe smallest item stays at the front

None of these classes hides a brand-new idea. They use arrays, references, and trees in ways that favour different operations.

A marker interface that changes real code

Both classes are lists, but ArrayList also has RandomAccess:

ArrayList  -> List, RandomAccess, Cloneable, Serializable, Collection, Iterable
LinkedList -> List, Deque, Cloneable, Serializable, Queue, Collection, Iterable

RandomAccess appears only on the first line. It has no methods. It is a marker interface from Section 8.5. It tells the library that reaching element i is cheap, as you saw in Section 4.2.

Collections.binarySearch checks for it:

if (list instanceof RandomAccess) {
    // use index based search
} else {
    // walk with an iterator instead
}

Repeated indexing of a linked list would be costly, so the library chooses another strategy. That one empty interface gives the library the information it needs.

Reading a signature

Methods in the framework use generics, and many use wildcards. Here is a real example:

boolean addAll(Collection<? extends E> c);

You can read each part now. E is this collection’s element type (9.3). ? extends E accepts a collection of E or a subtype of E (9.4). We use extends because this parameter produces elements for us to read. That is the PECS rule.

This phase applies the array, interface, and generics ideas you have already learned.

05

What it costs

There is a fair amount to learn: several classes, several interfaces, and the tradeoffs behind each choice. That is why this phase has eight sections.

The wrong choice may not cause an error. Use a LinkedList where an ArrayList would suit the work better, and the program still runs. A loop that should take a second might take a minute, with no warning from Java.

Generic collections hold objects, not primitives. List<int> does not compile because generic type arguments must be reference types, a restriction connected to type erasure from Section 9.3. Java therefore boxes each number, with the memory and time cost you measured in Section 7.3.

Interfaces promise behaviour, not speed. List says nothing about how long get(i) takes, so a method that receives a List cannot assume indexing is cheap. RandomAccess communicates that extra fact when it matters.

Start with ArrayList, measure your program, and change the implementation only if the profile points elsewhere. Shared interfaces keep that change local instead of turning it into a rewrite.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

  1. Why does Java have several collection types instead of one that does every job?

    Show the answer

    Because the way data is arranged always creates a tradeoff.

    An unsorted array lets you add at the end quickly, but finding its largest value means checking every element.

    A sorted array keeps the largest value at the end, but inserting a new value means shifting everything that comes after it.

    Each data structure makes some operations cheap and makes others cost more. Java provides several choices so you can choose the tradeoff that suits the job.

  2. `Map` is not a `Collection`. Why not, and what would go wrong if it were?

    Show the answer

    Because Collection is built around one thing per slot. Its central method is add(E e), taking a single element.

    A Map stores pairs. Its central method is put(K key, V value), taking two. An add(E e) method takes one element, so it cannot represent that operation.

    You can check this yourself: HashMap implements Map, Cloneable and Serializable, and nothing else. It is not Iterable either, which is why you cannot write for (x : myMap).

    To loop over a map you ask it for a collection first: keySet(), values() or entrySet(). Those are collections.

  3. `List<Integer> l = new ArrayList<>();` Why declare the variable as `List` rather than `ArrayList`?

    Show the answer

    It leaves room to change the implementation later.

    The variable's type decides what you are allowed to call. Declare it as List and you can call only List methods, which every list implementation provides. If you switch to a LinkedList, code that uses only those methods still compiles.

    Declare it as ArrayList and you might use an ArrayList-only method without noticing, and then the swap breaks compilation in places you did not expect.

    This is polymorphism from Section 7.2 in practice. A useful habit is to declare the interface and create the class.

07

Exercises

Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.

4 exercises85 pointsabout 100 minutes

The Layers of Logic VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

Prove the Trade Is Unavoidable

Real work·25 min·20 points

checkedex-10-1-a

Two arrangements of the same numbers. Measure both operations on both.

The table you produce is the point. One column wins the first row, the other wins the second, and neither wins both.

Then answer the last question honestly. To be fast at both you would need something that is neither a plain array nor a sorted array, and whatever you invent will be slow at a third thing.

That is not a limitation of Java. It is the shape of the problem, and it is why the next seven sections exist.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class TheTrade {
    static int findMaxUnsorted(int[] a, int size)
    static int findMaxSorted(int[] a, int size)
    static int[] insertUnsorted(int[] a, int size, int v)
    static int[] insertSorted(int[] a, int size, int v)
}

size is how many slots are in use, which is not the same as a.length. Both insert methods return the array to use next, because a full array has to be replaced by a bigger one.

What your program must do

  • Implement all four methods so the tests pass
  • findMaxSorted must read one slot. If it loops, you have not used the fact that it is sorted
  • Both insert methods must grow the array when every slot is used
  • In main, time both operations on both arrangements and print a table
  • Say which arrangement wins each row, and what you would give up to win both
TheTrade.java
import java.util.*;

public class TheTrade {

    // Walk every used slot and return the largest.
    static int findMaxUnsorted(int[] a, int size) {
        return 0; // TODO
    }

    // The array is already sorted. One read, no loop.
    static int findMaxSorted(int[] a, int size) {
        return 0; // TODO
    }

    // Put v at the end. Grow the array first if every slot is used.
    static int[] insertUnsorted(int[] a, int size, int v) {
        return a; // TODO
    }

    // Put v where it belongs, shifting the rest right. Grow first if full.
    static int[] insertSorted(int[] a, int size, int v) {
        return a; // TODO
    }

    public static void main(String[] args) {
        // TODO: build both arrangements with 100_000 numbers
        // TODO: time 10_000 max-lookups on each
        // TODO: time 10_000 insertions into each
        // TODO: print the table
    }
}
Hint 1
Unsorted max means checking every used slot. Sorted max is a[size - 1], one array read.
Hint 2
Growing is a = Arrays.copyOf(a, a.length * 2). Do it first, then insert, then return the array you ended up with.
Hint 3almost the answer
Sorted insert has two costs. Walk forward while a[at] <= v to find the spot, then copy backwards from size down to at + 1 so nothing is overwritten before it is moved.
What this is really testing

Whether you believe there is no best data structure, or have shown it. Two arrangements of the same numbers, and each is fast where the other is slow.

B

Swap the Implementation

Warm up·20 min·15 points

checkedex-10-1-b

Write one method that accepts every container in Java.

Then do the thing that locks you in. Declare a variable as ArrayList, call a method only ArrayList has, and then try to swap in a LinkedList.

Note where the error appears. It is not on the line you changed. It is on the line that used the extra method, which might be hundreds of lines away or in another file.

That distance is the argument for the rule. Write it down in your own words when you are done.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Swap {
    static String report(Collection<String> items)
    static Collection<String> largest(Collection<Collection<String>> groups)
}

report returns the size and the word items, like "2 items". largest returns whichever group holds the most. The parameter types are the exercise: pick anything narrower and the tests will not compile.

What your program must do

  • Make report work for all four containers without changing it once
  • Implement largest so it compares groups of different classes
  • Declare a variable as ArrayList, call an ArrayList-only method, then try to swap in a LinkedList
  • Write down which line the error appeared on, and which line you actually changed
  • State the rule in one line
Swap.java
import java.util.*;

public class Swap {

    // One method that accepts EVERY kind of container: List, Set and Deque alike.
    static String report(Collection<String> items) {
        return ""; // TODO
    }

    // Whichever group holds the most items.
    static Collection<String> largest(Collection<Collection<String>> groups) {
        return null; // TODO
    }

    public static void main(String[] args) {
        ArrayList<String> a = new ArrayList<>(List.of("Atlas", "Beacon"));
        LinkedList<String> l = new LinkedList<>(List.of("Cipher"));
        HashSet<String> h = new HashSet<>(List.of("Drift"));
        ArrayDeque<String> d = new ArrayDeque<>(List.of("Ember"));

        // TODO: pass all four to report()

        // TODO: declare a variable as ArrayList, call ensureCapacity() on it,
        //       then change the declared type to LinkedList. Record where the
        //       error appears, and how far it is from the line you changed.
    }
}
Hint 1
All four are a Collection. Take that as the parameter type and every one of them fits.
Hint 2
ensureCapacity exists on ArrayList and not on List. Once you call it, changing the variable's type to LinkedList stops compiling.
Hint 3almost the answer
The rule: declare the interface, create the class. List<String> x = new ArrayList<>(); The same applies to parameters and to return types, which is why largest returns a Collection and not an ArrayList.
What this is really testing

Whether you write to the interface by habit. Declaring the class instead of the interface compiles fine and quietly locks you in.

C

Map Is Not a Collection

Real work·20 min·20 points

checkedex-10-1-c

The hierarchy diagram says Map sits outside Collection. Prove it rather than trust it.

Print the interfaces each class implements and compare them. HashMap’s list is much shorter than ArrayList’s, and Iterable is missing from it.

Then try a for-each over a map and read the error. Then loop over it three ways that work.

Finish with the design reason, stated in terms of method signatures. One method takes one argument and the other takes two. That is the whole answer, and it is more satisfying than “because Java says so”.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class NotACollection {
    static boolean mapIsCollection()
    static boolean listIsCollection()
    static boolean mapIsIterable()
    static List<String> viaKeySet(Map<String, Integer> m)
    static List<Integer> viaValues(Map<String, Integer> m)
    static List<String> viaEntrySet(Map<String, Integer> m)
}

The three via methods each return a sorted list, so the answer does not depend on the order HashMap happens to use. viaEntrySet returns strings shaped like "Atlas=88".

What your program must do

  • Answer the three hierarchy questions by asking the type system, not by typing true or false
  • Print the interfaces of HashMap and ArrayList and compare the two lists
  • Write a for-each over a Map, read the error, then comment it out
  • Implement the three loops that do work
  • Explain the design reason in terms of method signatures
NotACollection.java
import java.util.*;

public class NotACollection {

    // Ask the type system rather than trusting the diagram.
    static boolean mapIsCollection() { return true;  /* TODO */ }
    static boolean listIsCollection() { return false; /* TODO */ }
    static boolean mapIsIterable()   { return true;  /* TODO */ }

    // Three loops that DO work over a Map. Each returns a sorted list.
    static List<String> viaKeySet(Map<String, Integer> m) {
        return List.of(); // TODO
    }

    static List<Integer> viaValues(Map<String, Integer> m) {
        return List.of(); // TODO
    }

    // Each entry becomes "key=value", for example "Atlas=88".
    static List<String> viaEntrySet(Map<String, Integer> m) {
        return List.of(); // TODO
    }

    public static void main(String[] args) {
        System.out.println("Is Map a Collection?    " + mapIsCollection());
        System.out.println("Is List a Collection?   " + listIsCollection());
        System.out.println("Is Map Iterable?        " + mapIsIterable());

        // TODO: print every interface HashMap and ArrayList implement, and compare
        // TODO: try to for-each over a Map directly. Read the error, then comment it out
    }
}
Hint 1
Collection.class.isAssignableFrom(Map.class) asks the question directly. HashMap.class.getInterfaces() gives you the list to compare.
Hint 2
The three working loops go through keySet(), values() and entrySet(). All three return real collections, which is why for-each accepts them.
Hint 3almost the answer
The design reason is in the signatures. Collection.add(E e) takes one thing. Map.put(K k, V v) takes two. There is no honest way to fit a pair into a method built for one element.
What this is really testing

Whether you can explain the one place the hierarchy diagram surprises people, using evidence rather than assertion.

D

Registry, Rebuilt on Collections

Real work·35 min·30 points·The Registry

checkedex-10-1-d

Go back to the Registry you built with arrays in Phase IV and rebuild it on the framework.

The point is the list of what disappears. In Phase IV you tracked a size separately from the array length, checked for capacity before adding, wrote a grow-and-copy method, and shifted elements when removing.

All of that is now inside ArrayList, and you wrote it yourself in the Section 4.2 exercises, so you know exactly what is happening.

Finish by swapping to a LinkedList and confirming nothing else in your file changes. If something breaks, you declared a variable as the class instead of the interface.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class RegistryCollections {
    static List<Unit> newRoster()
    static boolean removeById(List<Unit> roster, int id)
    static double averageReadiness(List<Unit> roster)
    static Unit mostReady(List<Unit> roster)
    static List<Unit> activeOnly(List<Unit> roster)
}

Unit is the record already in the file. An empty roster averages 0.0 and has no most ready unit, so mostReady returns null. activeOnly returns a new list and leaves the roster untouched.

What your program must do

  • Implement all five methods with no capacity handling anywhere
  • Swap ArrayList for LinkedList inside newRoster and confirm the tests still pass
  • Say why that swap changed nothing else in the file
  • List what you deleted compared with the Phase IV array version
RegistryCollections.java
import java.util.*;

public class RegistryCollections {

    record Unit(String name, int id, double readiness, boolean active) { }

    // In Phase IV this was a Unit[] with a separate size counter, a capacity
    // check before every add, a grow-and-copy method, and a shift loop on remove.
    // None of that appears below. That absence is the exercise.

    static List<Unit> newRoster() {
        return null; // TODO
    }

    // true when a unit was actually removed.
    static boolean removeById(List<Unit> roster, int id) {
        return false; // TODO
    }

    // 0.0 for an empty roster.
    static double averageReadiness(List<Unit> roster) {
        return 0.0; // TODO
    }

    // null for an empty roster.
    static Unit mostReady(List<Unit> roster) {
        return null; // TODO
    }

    // A new list. The roster passed in must not change.
    static List<Unit> activeOnly(List<Unit> roster) {
        return null; // TODO
    }

    public static void main(String[] args) {
        // TODO: build a roster, add units, report count, average and best
        // TODO: remove one by id
        // TODO: change newRoster to return a LinkedList and confirm nothing else moves
        // TODO: list what you deleted compared with the Phase IV version
    }
}
Hint 1
newRoster returns new ArrayList<>(), but its declared return type is List. That difference is what makes the later swap free.
Hint 2
To remove by id, roster.removeIf(u -> u.id() == id) does it in one line and already returns the boolean you need. That is a lambda, and Phase XI explains it properly.
Hint 3almost the answer
What you deleted: the size counter, the capacity check, the grow-and-copy method, the shift-on-remove loop, and the fixed array length. All of it now lives inside ArrayList, and you wrote it yourself in the Section 4.2 exercises.
What this is really testing

Whether you can replace hand-written array code with the framework and see what you deleted. The old version worked, and this is how much of it was scaffolding.

08

After the credits

Iterable sits at the top of the diagram, and it has exactly one method.

That method is why this line works for an ArrayList, a HashSet, and a TreeSet without any changes:

for (String name : anything) { }

Arrays also work with a for-each loop, but that is a separate rule in the Java language. You have used the loop since Section 4.1, where you learned that Java rewrites it behind the scenes. Section 10.2 looks at that rewrite closely.

You will also see why removing an element inside a for-each loop throws an exception, and how to remove it safely.

It is the smallest interface in the framework, and the one the other collection interfaces build on.

Threads you opened in this section

The Collections Framework will return in 10.2 - Iterable and the Iterator