← Java: language and application decisions
05 / 10 · 45 MIN

Collections, generics and keys

Choose structures, copies and signatures that preserve order, identity, data ownership and duplicate rules.

Choose structure from the contract

A list represents a sequence: order, position and repetitions may matter. A map associates each key with a value. A set represents membership without duplicates according to its equivalence rule. Before replacing one structure with another, write what must survive. A receipt containing A, B, A loses information if the second A is removed. An identifier index may require the opposite: detecting and rejecting that repetition.

Map.put does not itself implement duplicate rejection. In an empty map, put("A", 3) followed by put("A", 8) leaves one mapping with value 8. The previous entry was replaced. If policy requires preserving the first occurrence, the application must choose an appropriate operation and condition, including whether null is allowed.

An interface type does not promise every implementation characteristic. List need not mean modifiable list; Map promises no common iteration order across all implementations. Specify order, mutability, null handling and element ownership. Snippets assume the required java.util imports.

Similar operations, different effects

A List<Integer> has two remove operations with different meanings. remove(int) uses a position; remove(Object) searches for a value. For an ArrayList holding 4, 9, 1, remove(1) removes 9 and leaves 4, 1. By contrast, remove(Integer.valueOf(1)) finds value 1 and leaves 4, 9. The argument type selects the overload. Before accepting a fix, use an example where position and value lead to different outcomes.

Arrays.asList links a list to the supplied array. Given String[] raw = {"east", "west"}, replacement through view.set(0, "north") also changes raw[0]. Changing raw[1] is visible through view.get(1). The list has fixed size, so an add that would grow it fails. Fixed size differs from prohibiting set.

List.of and List.copyOf produce unmodifiable lists and prohibit null elements. new ArrayList<>(List.of(...)) creates another, modifiable structure; it does not inherit its source’s mutation restriction. These details determine whether an operation is valid, throws an exception or changes an object another component still uses.

View, copy and shared elements

Collections.unmodifiableList(staging) prevents mutation through the returned reference. It remains a view of its backing list, however. If the producer clears staging, the consumer observes that change. This mechanism suits a live view with controlled access; it does not satisfy a receipt required to preserve earlier state.

List.copyOf(staging) retains elements in source order and does not reflect later structural changes to that source. The returned list disallows add, remove and set. The factory may reuse a suitable list; do not base the contract on == or a promise of fresh allocation. The useful question is which changes remain observable.

A structural copy is not an automatic deep copy. If an element is a shared StringBuilder, appending to that object is visible through both lists. String contents, by contrast, are immutable. Thus List.copyOf satisfies the receipt case’s stability requirement with non-null String elements and a source stable during copying. With mutable elements, decide which data must be copied into independent values.

Generics and boundary safety

Integer is a Number, but List<Integer> is not List<Number>. If that assignment were permitted, a method accepting List<Number> could append Double to a list its caller still uses as List<Integer>. Checking protects that contract before elements are read or inserted; an empty list does not change the types.

A signature should express the required use. A method reading numbers from several list types may accept List<? extends Number>. The precise element type is unknown but bounded by Number: get can be read as Number. Appending Integer is unsafe through that reference because the concrete list could contain only Double. Nor can get be assigned directly to an Integer variable without further information.

Avoid fixing rejection by dropping type parameters or forcing casts. That exchanges an explicit boundary for an assumption that may fail later. Improve the signature by limiting the method to what its task needs. The cited official tutorials document stable generic rules; this lesson’s examples use the Java SE 25 baseline.

Supply values and receive values

For a destination required to receive Integer, List<? super Integer> accepts lists of Integer, Number or Object. Appending Integer is type-safe because all those destinations accept it. The reverse direction offers less information: an arbitrary read from that destination is guaranteed only as Object because the list may contain other types. Whether add succeeds at runtime still depends on operation support.

The usual guideline pairs extends with a source supplying values and super with a destination receiving them. Use it to reason about operations, not as an immutability promise. List<? extends Number> still permits writing a clear call from the type system’s perspective; an ArrayList may be cleared and an unmodifiable list may reject the operation. Reading get(0) may also compile but fail because position zero is absent.

When selecting a signature, make three separate checks: it accepts the required source/destination types, permits the body’s operations and respects the concrete collection’s capabilities. An answer that merely compiles does not establish every usage requirement.

Stable keys and ordering equivalence

A map key must keep equality-relevant data stable while it remains in the map. If equals and hashCode depend on a field changed by a setter, there is no general guarantee that lookup remains correct. Do not replace that conclusion with “get always returns null”: the contract promises no such specific outcome. An independent stable key, such as a String identifier, avoids dependence on a mutable business object’s evolution.

In a TreeSet, the comparison used for ordering also decides whether elements count as equivalent. If it compares only length, east and west are equivalent for that set even though String.equals distinguishes them. The second addition does not increase size. If the requirement is to retain both strings, the chosen equivalence is wrong.

A tie-break using full string contents can distinguish equal-length strings; another option is an ordered list when repetitions matter. Do not choose TreeSet merely because output must be ordered: first confirm its membership rule. Ordering and uniqueness are related but distinct decisions.

Presence, null and duplicate detection

A HashMap permits null values. Therefore get(id) == null can mean either an absent key or a present key with no known value. containsKey distinguishes them. This is decisive for a parser using null as pending state: identifier R7 has already been seen even before its final state is known.

putIfAbsent treats a null mapping as eligible for a new value. It therefore cannot prove that an identifier never existed in this model. The example below checks containsKey first; if the key exists, it returns DUPLICATE without put. Otherwise it stores the value, including null, and returns NEW. Policy retains the first mapping and records the additional occurrence separately.

This sequence is analyzed in a private map without concurrency or external calls between checking and writing. It is not a coordination solution for multiple threads. If two participants may write, redesign the boundary and state representation according to the chosen implementation’s contract. Answers in this lesson are selected by reading; they do not require executing the parser.

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

final class SeenStates {
    private final Map<String, String> states = new HashMap<>();

    // Sequential owner only. A null value means pending.
    String register(String id, String value) {
        Objects.requireNonNull(id);
        if (states.containsKey(id)) return "DUPLICATE";
        states.put(id, value);
        return "NEW";
    }
}
IN PRACTICE

In a fresh SeenStates, register("R7", null) returns NEW; register("R7", "accepted") then returns DUPLICATE and the mapping remains null. register("R8", "accepted") returns NEW. Analysis assumes one sequential owner and does not execute code.

Common pitfalls

Confusing an unmodifiable view with a snapshot; confusing removal by index with removal by value; treating extends as immutability; changing resident keys; interpreting null as absence without checking the contract.

Related topics: Methods, contracts and control flow · Objects, interfaces and records · Streams, lambdas and absence · Concurrency, state and cancellation

Take this idea with you

Check types, mutation capabilities, element ownership and equivalence separately. A collection’s name alone does not decide domain policy.

Create account

Reference: Java 25 API: List · Java SE 25; no preview features