7. Collections and Streams
Full examples: lessons/l07.
The collections framework
Section titled “The collections framework”The collections framework maps closely onto System.Collections.Generic. You program against an interface and choose an implementation:
| C# | Java interface | Usual Java implementation |
|---|---|---|
List<T> |
List<E> |
ArrayList |
LinkedList<T> |
List<E>, Deque<E> |
LinkedList (rarely the right choice) |
Dictionary<K, V> |
Map<K, V> |
HashMap |
SortedDictionary<K, V> |
SortedMap, NavigableMap |
TreeMap |
OrderedDictionary<K, V> (insertion order) |
SequencedMap |
LinkedHashMap |
HashSet<T> / SortedSet<T> |
Set<E> / NavigableSet<E> |
HashSet / TreeSet |
Queue<T>, Stack<T> |
Deque<E> |
ArrayDeque |
PriorityQueue<T, P> |
Queue<E> |
PriorityQueue (the element is its own priority) |
IEnumerable<T> |
Iterable<T> |
— |
IReadOnlyList<T>, IReadOnlyDictionary<K, V> |
none | — |
Collections hold references only, so a List<Integer> boxes every element (lesson 4). Don’t use Stack and Vector: they are synchronized legacy classes, and ArrayDeque replaces both.
Unmodifiable, but not read-only types
Section titled “Unmodifiable, but not read-only types”The last row of the table is the big difference. Java has no read-only collection interfaces. List.of returns a List, with an add method that throws at run time:
// List.of is unmodifiable, but its type is still List: the error comes at run time.List<String> fixed = List.of("a", "b");attempt("List.of add", () -> fixed.add("c"));attempt("List.of with null", () -> List.of("a", null));
// Arrays.asList is a fixed-size view that writes through to the array.String[] array = {"x", "y"};List<String> view = Arrays.asList(array);view.set(0, "changed");System.out.println(array[0]);attempt("Arrays.asList add", () -> view.add("z"));List.of add: UnsupportedOperationExceptionList.of with null: NullPointerExceptionchangedArrays.asList add: UnsupportedOperationExceptionA C# method that takes an IReadOnlyList<T> documents its contract in the type system. In Java, the contract lives in the Javadoc, and the usual defence is a copy. List.copyOf returns an unmodifiable list and generally doesn’t copy one that already is. Note also that List.of, Set.of and Map.of reject null elements.
| Java | What it is | Closest C# |
|---|---|---|
List.of(…), List.copyOf(list) |
unmodifiable copy, no nulls | list.ToImmutableList() |
Collections.unmodifiableList(list) |
read-only view: changes to list show through |
list.AsReadOnly() |
Arrays.asList(array) |
fixed-size view of the array: set writes through, add throws |
— |
stream.toList() |
unmodifiable list | ToList() gives a mutable one |
Maps return null
Section titled “Maps return null”The C# indexer throws KeyNotFoundException for a missing key. Java’s Map.get returns null, so a missing key and a key mapped to null look the same. The map’s default methods cover the usual C# patterns:
// Map.get returns null for a missing key instead of throwing.Map<String, Integer> stock = new HashMap<>(Map.of("apples", 3));System.out.println(stock.get("pears") + " " + stock.getOrDefault("pears", 0));
// merge and computeIfAbsent replace the TryGetValue dance.Map<String, Integer> counts = new TreeMap<>();for (String word : "to be or not to be".split(" ")) { counts.merge(word, 1, Integer::sum);}System.out.println(counts);Map<String, List<String>> byInitial = new LinkedHashMap<>();for (String word : List.of("tea", "coffee", "tonic")) { byInitial.computeIfAbsent(word.substring(0, 1), k -> new ArrayList<>()).add(word);}System.out.println(byInitial);null 0{be=2, not=1, or=1, to=2}{t=[tea, tonic], c=[coffee]}merge(key, 1, Integer::sum) is the counting idiom: it inserts 1, or combines the old value and 1. computeIfAbsent replaces if (!dict.TryGetValue(k, out var list)) dict[k] = list = new();. Unboxing the null of a missing key into an int throws a NullPointerException (lesson 2).
Iteration order is part of the choice
Section titled “Iteration order is part of the choice”HashMap and HashSet iterate in no particular order, like Dictionary. The immutable Map.of and Set.of go further and randomise their order on each JVM start, so tests can’t depend on it by accident. Running this one-liner four times on this machine printed two different orders:
System.out.println(Map.of("one", 1, "two", 2, "three", 3, "four", 4, "five", 5).keySet());[five, three, two, four, one][two, three, five, one, four][five, three, two, four, one][five, three, two, four, one]Choose TreeMap for sorted keys and LinkedHashMap for insertion order, as the examples above do. The course’s tests would be flaky otherwise.
Sequenced collections
Section titled “Sequenced collections”Java 21 added sequenced collections: List, Deque, LinkedHashSet and sorted sets share getFirst, getLast, addFirst and a reversed() view, the counterparts of LINQ’s First(), Last() and Reverse(). LinkedHashMap and TreeMap get firstEntry, lastEntry and reversed():
// Java 21 sequenced collections: first, last and a reversed view.List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6));System.out.println(numbers.getFirst() + " " + numbers.getLast() + " " + numbers.reversed());1 6 [6, 5, 4, 3, 2, 1]Modifying while iterating
Section titled “Modifying while iterating”Both platforms detect a collection modified during a foreach. Java throws ConcurrentModificationException, .NET InvalidOperationException: Collection was modified; enumeration operation may not execute. The fix in Java is removeIf, the counterpart of List<T>.RemoveAll:
// Removing while iterating fails fast; removeIf is the safe way.attempt("remove in for-each", () -> { for (Integer n : numbers) { if (n % 2 == 0) { numbers.remove(n); } }});numbers.removeIf(n -> n % 2 == 0);System.out.println(numbers);remove in for-each: ConcurrentModificationException[1, 3, 5]numbers.remove(n) works here only because n is an Integer: with an int it would remove by index (lesson 4).
Streams: LINQ to Objects
Section titled “Streams: LINQ to Objects”A stream is a lazy pipeline of operations over a source, like a LINQ query over IEnumerable<T>. The names differ:
| LINQ | Stream | Note |
|---|---|---|
Where |
filter |
|
Select |
map |
mapToInt, mapToDouble for primitive streams |
SelectMany |
flatMap |
the function returns a Stream |
OrderBy(x => x.K), ThenBy |
sorted(Comparator.comparing(X::k).thenComparing(…)) |
lesson 6 |
Distinct, Take, Skip |
distinct, limit, skip |
|
TakeWhile, SkipWhile |
takeWhile, dropWhile |
|
First(), FirstOrDefault() |
findFirst() |
returns an Optional (lesson 5) |
Any, All |
anyMatch, allMatch |
also noneMatch |
Count() |
count() |
returns a long |
Sum, Min, Max, Average |
mapToInt(…).sum(), min(Comparator), max(Comparator), average() |
|
Aggregate |
reduce |
|
GroupBy |
collect(Collectors.groupingBy(…)) |
builds a Map, not a lazy sequence |
ToDictionary |
collect(Collectors.toMap(…)) |
|
ToList() |
toList() |
unmodifiable |
Chunk(n) |
gather(Gatherers.windowFixed(n)) |
Java 24 |
Zip |
none | exercise 3 |
// Where / Select / OrderBy / ToListList<String> bigOrders = ORDERS.stream() .filter(o -> o.total() >= 50) .sorted(Comparator.comparingDouble(Order::total).reversed()) .map(o -> o.customer() + ":" + o.product()) .toList();System.out.println(bigOrders);
// GroupBy + Sum, with a sorted map for a stable orderMap<String, Double> totals = ORDERS.stream() .collect(Collectors.groupingBy(Order::customer, TreeMap::new, Collectors.summingDouble(Order::total)));System.out.println(totals);[ada:monitor, ada:keyboard, alan:mouse]{ada=478.0, alan=72.5, grace=25.0}Collecting goes through Collectors. groupingBy takes a key function, an optional map factory (TreeMap::new here) and a downstream collector that aggregates each group. It is the GroupBy(…).Select(g => new { g.Key, Total = g.Sum(…) }) pattern in one call.
// Any / All / First / Distinct / CountSystem.out.println(ORDERS.stream().anyMatch(o -> o.quantity() > 4) + " " + ORDERS.stream().allMatch(o -> o.price() > 1) + " " + ORDERS.stream().filter(o -> o.product().equals("mouse")).findFirst().map(Order::customer).orElse("none") + " " + ORDERS.stream().map(Order::product).distinct().count());
// SelectMany, Chunk (Java 24 gatherers) and primitive streamsSystem.out.println(Stream.of("a,b", "c").flatMap(s -> Stream.of(s.split(","))).toList());System.out.println(IntStream.rangeClosed(1, 7).boxed().gather(Gatherers.windowFixed(3)).toList());var stats = ORDERS.stream().mapToInt(Order::quantity).summaryStatistics();System.out.println("quantities: sum " + stats.getSum() + ", min " + stats.getMin() + ", max " + stats.getMax());true true alan 4[a, b, c][[1, 2, 3], [4, 5, 6], [7]]quantities: sum 11, min 1, max 5Gatherers, final since Java 24, are the extension point for custom intermediate operations, the role C# fills with extension methods on IEnumerable<T>. Gatherers provides windowFixed, windowSliding, fold, scan and mapConcurrent.
Lazy, and single-use
Section titled “Lazy, and single-use”Like LINQ, a stream runs nothing until a terminal operation (toList, count, findFirst…), and elements pass through the whole pipeline one at a time:
// Streams are lazy: nothing runs until a terminal operation, and elements flow one at a time.Stream<String> pipeline = Stream.of("one", "two", "three") .peek(s -> System.out.println(" filter " + s)) .filter(s -> s.length() == 3) .peek(s -> System.out.println(" map " + s)) .map(String::toUpperCase);System.out.println("pipeline built");System.out.println(pipeline.findFirst().orElseThrow());pipeline built filter one map oneONEUnlike an IEnumerable<T>, a stream is not a reusable query. It is consumed by its terminal operation, and a second one throws:
// A stream can be consumed only once, unlike an IEnumerable.Stream<String> once = Stream.of("x");once.count();try { once.count();} catch (IllegalStateException e) { System.out.println(e.getMessage());}stream has already been operated upon or closedThe C# side enumerates the same Select query twice and prints 5 5. In Java, keep the collection and call stream() again, or pass a Supplier<Stream<T>>. A stream is not an Iterable either, so for doesn’t accept it:
import java.util.stream.Stream;
class Names { static void print(Stream<String> names) { for (String name : names) { System.out.println(name); } }}StreamIsNotIterable.java:5: error: for-each not applicable to expression type for (String name : names) { ^ required: array or java.lang.Iterable found: Stream<String>1 errorWhat the compiler catches
Section titled “What the compiler catches”A C# query is already an IEnumerable<T>. A stream has to be collected, and forgetting it is a type error with a message about inference:
import java.util.List;
class Upper { static List<String> upper(List<String> names) { return names.stream().map(String::toUpperCase); }}StreamIsNotAList.java:5: error: incompatible types: no instance(s) of type variable(s) R exist so that Stream<R> conforms to List<String> return names.stream().map(String::toUpperCase); ^ where R,T are type-variables: R extends Object declared in method <R>map(Function<? super T,? extends R>) T extends Object declared in interface Stream1 errorsum() exists only on primitive streams, because a Stream<T> can’t know how to add Ts. C#’s Sum has overloads for IEnumerable<int>, IEnumerable<double> and so on; Java makes you convert first with mapToInt(Integer::intValue):
import java.util.List;
class Totals { static int total(List<Integer> quantities) { return quantities.stream().sum(); }}SumOnBoxedStream.java:5: error: cannot find symbol return quantities.stream().sum(); ^ symbol: method sum() location: interface Stream<Integer>1 errorAnd findFirst returns an Optional, never the element or null:
import java.util.List;
class First { static String firstLong(List<String> words) { return words.stream().filter(w -> w.length() > 3).findFirst(); }}FindFirstIsOptional.java:5: error: incompatible types: Optional<String> cannot be converted to String return words.stream().filter(w -> w.length() > 3).findFirst(); ^1 errorTwo run-time traps
Section titled “Two run-time traps”// toList() is unmodifiable; toMap rejects duplicate keys.try { bigOrders.add("more");} catch (UnsupportedOperationException e) { System.out.println("toList() result is unmodifiable");}try { ORDERS.stream().collect(Collectors.toMap(Order::customer, Order::product));} catch (IllegalStateException e) { System.out.println(e.getMessage());}toList() result is unmodifiableDuplicate key ada (attempted merging values keyboard and monitor)ToDictionary fails the same way in C# (An item with the same key has already been added. Key: ada). toMap takes a third argument, a merge function, to decide instead. Collectors.toList(), the pre-Java 16 spelling, returns a mutable ArrayList in practice, but its Javadoc guarantees nothing about mutability.
Parallel streams (parallelStream()) exist, like PLINQ’s AsParallel(). Lesson 9 covers when they help.
Key takeaways
Section titled “Key takeaways”- Same shapes as
System.Collections.Generic, but no read-only interfaces:List.ofandtoList()throwUnsupportedOperationExceptionat run time. Map.getreturnsnull; usegetOrDefault,mergeandcomputeIfAbsent.- Don’t rely on
HashMaporder, and never onMap.oforder, which changes between runs. ChooseTreeMaporLinkedHashMap. - Streams are LINQ with different names. They are lazy, consumed once, not
Iterable, and collected withCollectors. findFirstreturns anOptional,countalong, andsumneeds a primitive stream.
Exercises
Section titled “Exercises”- Translate this query, which finds the product with the largest total quantity:
var best = orders.GroupBy(o => o.Product) .Select(g => new { Product = g.Key, Quantity = g.Sum(o => o.Quantity) }) .OrderByDescending(x => x.Quantity) .First().Product;Solution
static String bestSeller(List<Order> orders) { return orders.stream() .collect(Collectors.groupingBy(Order::product, Collectors.summingInt(Order::quantity))) .entrySet().stream() .max(Map.Entry.comparingByValue()) .map(Map.Entry::getKey) .orElseThrow();}groupingBy is terminal, so the grouped result is a Map, and you stream its entrySet() to continue. max avoids sorting everything. Java has no anonymous types; Map.Entry or a small record plays their role. With the lesson’s orders, the answer is cable (5 units).
- Write
topWords(String text, int n), returning thenmost frequent words asword=count. Break ties alphabetically, and ignore case and punctuation.
Solution
static List<String> topWords(String text, int n) { return Arrays.stream(text.toLowerCase().split("\\W+")) .filter(w -> !w.isEmpty()) .collect(Collectors.groupingBy(w -> w, Collectors.counting())) .entrySet().stream() .sorted(Map.Entry.<String, Long>comparingByValue(Comparator.reverseOrder()) .thenComparing(Map.Entry.comparingByKey())) .limit(n) .map(e -> e.getKey() + "=" + e.getValue()) .toList();}topWords("To be, or not to be.", 3) gives [be=2, to=2, not=1]. The explicit type witness Map.Entry.<String, Long>comparingByValue(…) is needed because inference can’t see through the chained thenComparing: without it, javac reports “no suitable method found for thenComparing”. It is a common Java annoyance with no C# counterpart.
- Java streams have no
Zip. Writezip(List<A>, List<B>, BiFunction<A, B, R>), stopping at the shorter list like LINQ’sZip.
Solution
static <A, B, R> List<R> zip(List<A> first, List<B> second, BiFunction<A, B, R> combine) { return IntStream.range(0, Math.min(first.size(), second.size())) .mapToObj(i -> combine.apply(first.get(i), second.get(i))) .toList();}Streaming the indices works for Lists with random access. For arbitrary iterables, you would walk two Iterators in a loop. zip(List.of("keyboard", "mouse", "cable"), List.of(1, 2), (p, q) -> p + "x" + q) gives [keyboardx1, mousex2].