DEV Community

Sanjeet Singh Jagdev
Sanjeet Singh Jagdev

Posted on

I Re-implemented Java Streams to Understand Lazy Evaluation

Why do this?

I have always been very fascinated by how some libraries that I use day-to-day provide us with such easy to use interfaces, all while elegantly hiding the complexity and using efficient techniques.

I wanted to learn this art by implementing some simple yet challenging!

What is Lazy Evaluation?

In simple terms, lazy evaluation means deferring the execution of an expression or piece of code until its result is actually needed.

The best example of this would be the Java Streams API, where we can define certain operations on a collection and then apply a terminal operation on it to collect the result(s) while applying those operations.

E.g.

List<Integer> r = List.of(1, 2, 3, 4, 4, 5, 6, 6, 7, 8, 9, 10)
   .stream()
   .filter(n -> n % 2 == 0)
   .map(n -> n * 2)
   .toList();
Enter fullscreen mode Exit fullscreen mode

Here, filter(...) and map(...) merely describe what should happen. The actual computation happens only when toList() is invoked.

So how do we apply this paradigm to our own code?

To understand this I decided to create a basic implementation of Java Streams from scratch!

Note: This article focuses on understanding lazy evaluation, not on building a production-ready Streams implementation.

Defining the Flow.java

For the sake of differentiating from Streams let's call this class Flow.

For simplicity this class will accept only a List<T>, this way we can focus on the Lazy aspect rather than interoperability with all possible java collections.

Let's define the class

class Flow<T> {
private final List<T> collection;
    private final List<T> list;
    private final List<Function<T, T>> ops;

    private Flow(List<T> list) {
        this.list = list;
        this.ops = new ArrayList<>();
    }

    // static method to create a "Flow"
    public static <U> Flow<U> of(List<U> c) {
        return new Flow<>(c);
    }
}
Enter fullscreen mode Exit fullscreen mode

We add a static method of() to create the Flow object (A constructor would work fine as well).

Now in order to perform action Lazily we need to store them so that they can referenced later and evaluated. That is where the List<Function<T, T>> ops comes to the picture.

Why the List<Function<T, T>> ops ?

By forcing every intermediate operation to conform to T → T, we can chain operations uniformly and apply them in sequence for each element. This simplification makes the lazy pipeline easy to reason about, even though it introduces limitations.
We will collect all operations inside this list and later apply them.

Intermediate Operations

Just like Streams API we will introduce a couple of intermediate operations that can be applied to the items in the list.

In this example we will implement the following operations:

  • map
  • filter
  • peek

map()

@SuppressWarnings("unchecked")
public <R> Flow<R> map(Function<T, R> fn) {
    ops.add(t -> (T) fn.apply(t));
    return (Flow<R>) this;
}
Enter fullscreen mode Exit fullscreen mode

This is the core principle behind lazy evaluation. Storing the operation/expression. In the ops list we store the operation as a wrapper function that simply calls the original function and returns its result. (Yes I am aware that this involves an unsafe cast and this implementation might break in some cases).

filter()

public Flow<T> filter(Predicate<T> p) {
    ops.add(t -> p.test(t) ? t : null);
    return this;
}
Enter fullscreen mode Exit fullscreen mode

Again, we wrap the predicate call as function which simply tests the condition and returns the original value or null in case the test fails.

Note: This implementation uses null as a signal to indicate that an element should be dropped. While this works for demonstration purposes, real stream implementations avoid this approach due to safety and composability concerns.

peek()

public Flow<T> peek(Consumer<T> fn) {
    ops.add(t -> {
        fn.accept(t);
        return t;
    });
    return this;
}
Enter fullscreen mode Exit fullscreen mode

For peek there is no side effect and we just want to run the function and return the original value.

Hopefully now the List<Function<T, T>> ops is starting to make sense as we can express each type of operation using this wrapper function.

Note: This trick with Function<T, T> quickly fails when we want to apply operations like limit(n) or skip(n), as it will require creating a new sub-list with these values, hence we won't truly iterate the list just once.

Terminal Operations

For terminal operations we will implement the following:

  • forEach
  • toList
  • reduce

forEach()

public void forEach(Consumer<T> fn) {
    for (T item : list) {
        for (var op : ops) {
            if (item != null) {
                item = op.apply(item);
            }
        }

        // Added to handle the 'false' evaluation of the predicate()
        if (item != null) {
            fn.accept(item);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This right here is Lazy Evaluation. Notice that each element flows through the entire pipeline before the next element is processed. This allows the collection to be traversed only once, which is a defining characteristic of lazy stream processing.

The intermediate operations (map, filter, peek) do not execute immediately. They only record intent by storing functions. Execution is deferred until a terminal operation iterates the source collection.

Moving on to other operations.

toList()

public List<T> toList() {
    List<T> list = new ArrayList<>();

    for (T item : this.list) {
        for (var op : ops) {
            if (item != null) {
                item = op.apply(item);
            }
        }

        // Added to handle the 'false' evaluation of the predicate()
        if (item != null) {
            list.add(item);
        }
    }
    return list;
}
Enter fullscreen mode Exit fullscreen mode

Same structure but in this case we collect the final items to a list.

reduce()

public T reduce(T initial, BinaryOperator<T> accumulator) {
    T finalVal = initial;

    for (T item : list) {
        for (var op : ops) {
            if (item != null) {
                item = op.apply(item);
            }
        }

        // Added to handle the 'false' evaluation of the predicate()
        if (item != null) {
            finalVal = accumulator.apply(finalVal, item);
        }
    }

    return finalVal;
}
Enter fullscreen mode Exit fullscreen mode

Finally with the sample principle yet again, we perform the reduce operation like we have in the Streams API.

An Example with Flow

class FlowTest {
    public static void main(String[] args) {
        var intList = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        var sumOfTwiceOfEven = Flow.of(intList)
                .filter(n -> n % 2 == 0)
                .peek(n -> System.out.println("Saw : " + n))
                .map(n -> n * 2)
                .reduce(0, (sum, i) -> sum + i);

        System.out.println(sumOfTwiceOfEven);
    }
}

// Output:
// Saw : 2
// Saw : 4
// Saw : 6
// Saw : 8
// Saw : 10
// 60
Enter fullscreen mode Exit fullscreen mode

As you can see this closely resembles how the Streams API looks and with just a few lines of code we implemented Lazy Evaluation.

What this implementation does NOT handle

  • Short-circuiting operations like findFirst
  • Stateful operations like limit or skip
  • Type safety across transformations

What did I learn?

Implementing even a simplified version of a familiar API made one thing clear: good abstractions are hard-earned. Lazy evaluation is not magic — it’s the result of carefully controlling when and how work is performed. Building this helped me appreciate both the power and the complexity hidden behind everyday libraries.

The entire code for this example can be found in this Github Gist

How would you redesign this pipeline to avoid the shortcomings like

  • Using null for predicate
  • Adding short-circuiting operations
  • Ensuring type safety across transformations

Top comments (0)