Lec 09 - InfiniteList and Stream
Eager List vs. Infinite List
EagerList: Stores actual values directly in fields (
T head, EagerList<T> tail)InfiniteList: Stores producers of values (
Producer<T> head, Producer<InfiniteList<T>> tail)
Always use this recursive thinking — A list is made up of its head element and the rest ("tail") when dealing with Eager List and Infinite List.
Method Implementations
head()
public T head() {
return this.head;
}public T head() {
T h = this.head.produce();
// Recursive call until head is not null
return h == null ? this.tail.produce().head() : h;
}Difference: EagerList directly returns the stored value, while InfiniteList produces the value on demand and handles null by recursively checking the tail.
tail()
Purpose: Returns the list without its first element.
public EagerList<T> tail() {
return this.tail;
}public InfiniteList<T> tail() {
T h = this.head.produce();
// Recursive call until tail is not null
return h == null ? this.tail.produce().tail() : this.tail.produce();
}Difference: EagerList directly returns the stored tail list, while InfiniteList produces the tail only when needed and handles null values.
get(int n)
Purpose: Retrieves the element at position n in the list.
public T get(int n) {
if (n == 0) {
return this.head();
}
return this.tail().get(n - 1);
}public T get(int n) {
if (n == 0) {
return this.head(); // be careful!
} // use the methods
return this.tail().get(n - 1); // instead of fields
}Difference: Both implementations use the same recursive approach, but InfiniteList's implementation inherently evaluates lazily due to its structure.
generate()
Purpose: Creates a list with repeated values.
// generate(1, 4) -> 1 1 1 1
// generate(4, 1) -> 4
public static <T> EagerList<T> generate(T t, int size) {
if (size == 0) {
return empty();
}
return new EagerList<>(t, generate(t, size - 1));
}// generate(() -> 1) gives us 1 1 1 1 1 ...
public static <T> InfiniteList<T> generate(Producer<T> t) {
return new InfiniteList<>(t, () -> generate(t));
}Difference: EagerList creates a fixed-size list with a repeated value, while InfiniteList creates a potentially infinite list using a producer function (so there is no need for size parameter)
iterate()
Purpose: Creates a sequence by repeatedly applying a transformation.
// iterate(1, x -> x <= 10, x -> x + 1) gives us 1 2 3 4 5 6 7 8 9 10
public static <T> EagerList<T> iterate(
T init, BooleanCondition<? super T> cond, Transformer<? super T, ? extends T> op) {
if (!cond.test(init)) {
return empty();
}
return new EagerList<>(init, iterate(op.transform(init), cond, op));
}// iterate(1, x -> x + 2) gives us 1 3 5 7
public static <T> InfiniteList<T> iterate(T init, Transformer<T, T> next) {
return new InfiniteList<>(() -> init, () -> iterate(next.transform(init), next));
}Difference: EagerList requires a condition to terminate the iteration, while InfiniteList can continue indefinitely, creating a potentially infinite sequence.
map()
Purpose: Transforms each element in the list using a provided function.
// (1 2 3 4).map(x -> x * x) gives us (1, 4, 9, 16)
public <R> EagerList<R> map(Transformer<? super T, ? extends R> mapper) {
return new EagerList<>(mapper.transform(this.head()), this.tail.map(mapper));
}// (1 1 1 1 ...).map(x -> x * 2) gives us (2 2 2 2 ...)
public <R> InfiniteList<R> map(Transformer<? super T, ? extends R> mapper) {
return new InfiniteList<>(() -> mapper.transform(this.head()),
() -> this.tail().map(mapper));
}Difference: EagerList immediately applies the transformation to each element, while InfiniteList creates a new list with transformation functions that will execute only when the values are needed.
filter()
Purpose: Creates a new list containing only elements that satisfy a condition.
// (1 2 3 4).filter(x -> x % 2 == 0) gives us (2, 4)
public EagerList<T> filter(BooleanCondition<? super T> cond) {
if (cond.test(this.head())) {
return new EagerList<>(this.head(), this.tail().filter(cond));
}
return this.tail.filter(cond);
}public InfiniteList<T> filter(BooleanCondition<? super T> cond) {
Producer<T> newHead = () -> (cond.test(this.head()) ? this.head() : null);
return new InfiniteList<>(newHead, () -> this.tail().filter(cond));
}Difference: EagerList immediately evaluates conditions and creates a new list with only matching elements. InfiniteList creates a producer that will check the condition only when the element is accessed, returning null for elements that don't match.
Tips
In summary, here are some tips
A lambda can only be passed to a method if the method's parameter is a Functional Interface
The lambda passed in defines how the functional interface works!
For the InifiniteList, the element will only be evaluated when we call either
get()(indirectly callinghead()) orhead()directly.
Under the Hood
For the Infinite List, what on earth happen if we try to invoke the following code, where evens is an Infinite List with 0, 2, 4, 6, 8, ....
First, let's rewrite this statement by adding some intermediate variables
First, when we create an evens,
The heap should look as follow,

evens is an object which has two fields, head and tail, and each points to an instance of the Producer functional interface (a.k.a, a lambda expression shown in the figure). As we have seen before,
a lambda expression is essentially syntactic sugar for writing an anonymous class that implements a functional interface.
so, here inside these two lambdas, it will follow the exact rules of variable capture, which are
the local class (including the anonymous class) will capture the following variables
The local variables of the method where the local class comes from (including only the arguments that the lambda uses, see more in Diagnostic Quiz Q13)
The instance that invokes the method where the local class comes from. (See more in Rec 05)
So, the two lambdas capture the variable init. The tail additionally captures the variable next, which itself is an instance of Transformer<T, T>.
Similarly, you will get the final graph shown like below

For more information, please see from the lecture notes.
Streams
Our Infinite List has its counterpart in the Java implementation, which is called Stream, but with more functionalities. All the methods introduced below are called stream operations!
Building a Stream
We can build a stream by using
of
We can use the static factory method of (e.g., Stream.of(1, 2, 3)).
Method Declaration
t is the element of the Stream.
Example
generate() and iterate()
We can use the generate and iterate methods (similar to our InfiniteList)
Method Declaration
Supplier is the same as Producer in CS2030S.
seed is the initial element, hasNext is an optional ending condition, which is a Predicate which is equivalent to BooleanCondition in CS2030S. next is a UnaryOperator<T> which is equivalent to Transformer<T, T> in CS2030S.
Example
Arrays::stream
We can convert an array into a Stream using Arrays::stream
List::stream
We can convert a List instance (or any Collection instance) into a Stream using List::stream
Terminal Operations
A terminal operation is an operation on the stream that triggers the evaluation of the stream. A typical way of writing code that operates on streams is to chain a series of intermediate operations together, ending with a terminal operation.
forEach
The forEach method is a terminal operation that takes in a stream and applies a lambda expression to each element.
The lambda expression to apply does not return any value. Java provides the Consumer<T> functional interface for this. We have already seen pretty much examples from Building a Stream.
Method Declaration
action is a Consumer, which is equivalent to Consumer in CS2030S.
Example
count()
The count() method in Java Streams is a terminal operation that returns the number of elements in the stream.
reduce()
The reduce operation applies a binary function (or a.k.a, a Combiner in CS2030S) to each element in the stream to reduce them to a single result.
Method Declaration
The BinaryOperator is equivalent to Combiner in CS2030S.
Example
Here, 0 is called the identity value, (a,b) -> a + b is called an accumulation function. This process is equivalent to the following pseudocode:
noneMatch()
The noneMatch operation returns true if no elements in the stream match the given predicate, short-circuiting (stopping early) if any matching element is found.
Method Declaration
Predicate is equivalent to BooleanCondition in CS2030S.
Example
allMatch()
The allMatch operation returns true if all elements in the stream match the given predicate, short-circuiting if any non-matching element is found.
Method Declaration
Predicate is equivalent to BooleanCondition in CS2030S.
Example
anyMatch()
The anyMatch operation returns true if at least one element in the stream matches the given predicate, short-circuiting once a matching element is found.
Method Declaration
Predicate is equivalent to BooleanCondition in CS2030S.
Example
Intermediate Stream Operations
An intermediate operation on stream returns another Stream. Intermediate operations are lazy and do not cause the stream to be evaluated.
map()
The map operation transforms each element in the stream by applying a function to it, producing a new stream of the transformed elements with a one-to-one relationship.
Method Declaration
Function is equivalent to Transformer<T,R> in CS2030S.
Example
flatMap()
The flatMap operation transforms each element into a stream and then flattens all resulting streams into a single stream, useful for working with nested collections or when one element should produce multiple output elements
Method Declaration
Function is equivalent to Transformer<T,R> in CS2030S.
Example
flatMap takes each element in the stream (each inner list) and transforms it into a new stream of its own elements (e.g. a stream of 1,2, a stream of 3, 4, 5 and a stream of 6) . Then, it "flattens" all these streams into one single stream.
filter()
The filter operation selects elements from the stream that satisfy a given predicate (boolean condition), creating a new stream that contains only the elements that passed the test.
Method Declaration
Predicate is equivalent to BooleanCondition in CS2030S.
Example
sorted()
The sorted operation arranges elements according to natural order or a provided comparator, requiring the entire stream to be processed before producing any results.
distinct()
The distinct operation filters out duplicate elements based on their equals() method, returning a stream with only unique elements.
Both sorted() and distinct() operations are stateful (they maintain internal state about previously seen elements) and bounded (they need to process all elements before producing results, a.k.a, they should only be called on a finite stream), which impacts performance especially with large datasets.
limit()
The limit operation restricts the stream to process only a maximum number of elements, which can improve performance for large or infinite streams.
takeWhile()
The takeWhile operation (introduced in Java 9) takes elements from the stream as long as the predicate returns true, stopping at the first element that fails the condition.
Method Declaration
Predicate is equivalent to BooleanCondition in CS2030S.
Example
limit() and takeWhile() are intermediate opeartions that convert from infinite stream to finite stream. (Although finite, but still lazy evaluated)
peek()
The peek operation creates a "fork" in the stream pipeline, allowing us to perform side effects (like debugging) without modifying the elements flowing through the stream. It takes a Consumer function that can view or process each element while letting the original elements continue unchanged through the pipeline.
Method Declaration
Consumer is equivalent to Consumer in CS2030S.
Example
Consumed Once
One of the greatest limitations of Stream, which does not apply to our InfiniteList, is that a stream can only be operated once.
Specialized Stream
Java provides specialized streams (IntStream, LongStream, and DoubleStream) to work efficiently with primitive types, these streams have the following advantages
Avoid the boxing/unboxing overhead of wrapper classes
This can be simplified to as follows:
Offer specialized methods like sum() and average() for numerical operations
Can be created through factory methods like range(), rangeClosed(), or by converting from regular streams using mapToInt(), mapToLong(), or mapToDouble()
These specialized streams maintain the same pipeline pattern as regular streams while providing better performance for numeric computations.
Elegance of Stream
This can be vividly shown by using an example to count the first 500 primes. Initially, our code looks like,
By using Java Streams, this can be simplified to
Last updated