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)

Method Implementations

1

head()

public T head() {
  return this.head;
}

Difference: EagerList directly returns the stored value, while InfiniteList produces the value on demand and handles null by recursively checking the tail.

2

tail()

Purpose: Returns the list without its first element.

public EagerList<T> tail() {
  return this.tail;
}

Difference: EagerList directly returns the stored tail list, while InfiniteList produces the tail only when needed and handles null values.

3

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);
}

Difference: Both implementations use the same recursive approach, but InfiniteList's implementation inherently evaluates lazily due to its structure.

When n ≠ 0, due to the implementation of tail(), the InfiniteList will evaluate the elements from the start all the way until getting the element you want! This may be useful in stack and heap tracing.

4

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));
}

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)

5

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));
  }

Difference: EagerList requires a condition to terminate the iteration, while InfiniteList can continue indefinitely, creating a potentially infinite sequence.

6

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));
}

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.

7

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);
}

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

  1. A lambda can only be passed to a method if the method's parameter is a Functional Interface

  2. The lambda passed in defines how the functional interface works!

  3. For the InifiniteList, the element will only be evaluated when we call either get() (indirectly calling head()) or head() directly.

Revisit Lazy Evaluation

Lazy evaluation means we can delay a computation using the Producer functional interface. So, instead of doing compute() which is immediately evaluated when executed, we replace it with a Producer () -> compute(), which "stores" the computation in an instance of Producer, and we only call it when we invoke the produce method. (In Infinite List, invoking the produce method is done by calling head() directly or indirectly).

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

  1. 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)

  2. 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.

  1. When you call infiList1 = InfiniteList.operation, the head and tail fields in the infiList1 point to two anonymous classes implemented in lambdas which capture the variable appeared in that lambda.

  2. When you call infiList2 = infiList1.opeartion, the head and tail fields in teh infiList2 point to two anonymouse classes also, but with another this field in each of them, which points to infiList1.

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

1

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

2

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.

Example

3

Arrays::stream

We can convert an array into a Stream using Arrays::stream

4

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.

1

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

2

count()

The count() method in Java Streams is a terminal operation that returns the number of elements in the stream.

count processes the entire stream, so calling it on an infinite stream will cause an infinite loop.

3

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:

4

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

5

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

6

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.

1

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

2

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.

Why Collection not List here? It is because Collection is a broader concept that includes List. It’s a way to write flexible code that could work with other collection types (like Set) if needed. Here, since the inner elements are Lists, it works perfectly.

3

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.

What if no element passes the predicate?

Method Declaration

Predicate is equivalent to BooleanCondition in CS2030S.

Example

4

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.

5

distinct()

The distinct operation filters out duplicate elements based on their equals() method, returning a stream with only unique elements.

6

limit()

The limit operation restricts the stream to process only a maximum number of elements, which can improve performance for large or infinite streams.

7

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

8

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

1

Avoid the boxing/unboxing overhead of wrapper classes

This can be simplified to as follows:

2

Offer specialized methods like sum() and average() for numerical operations

3

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