Being Lazy in Go

Being Lazy in Go

In programming laziness means a delayed evaluation of expressions - evaluating expressions only when it is needed. This is in contrast to a strict evaluation where expressions are evaluated right away.

Go, like most of other programming languages, uses the strict evaluation strategy.

When we do a variable assignment in Go, an expression on the right side is evaluated first, and then a resulting value of that evaluation is assigned to the variable:

r := doSomeComputation(a, b, c)

Or when we call a function, its arguments are evaluated before the function is actually called. In the case below, calculate1() + calculate2() expression is evaluated first, and only then a result of this evaluation is passed to the function myFunc:

r := myFunc(calculate1() + calculate2())

This strict evaluation behavior is what most developers are used to and what we want most of the times anyways in practice. And while some programming languages provide special syntax support for delayed evaluations, there is no built-in support for that in Go. However, of course, it could be simulated by wrapping expressions in function values to be evaluated later e.g.:

func myFunc(lazy func() int) int {
    evaluated := lazy()
    //work with the evaluated value
}

r := myFunc(func() int { return calculate1() + calculate2()})

This technique of delaying a computation until its result is needed is often called a Thunk.

Lazy Transformations

Another aspect of laziness is a concept of lazy transformations when working with (possibly) large data structures. Let's consider this sequence of operations (Seq is a generic type based on Go slices such as type Seq[A any] []A, with additional support for common functional programming operations, provided by Go4Fun library):

r := Seq[int]{-2, -1, 0, 1, 2, 3, 4, 5, 6}.
        Filter(func(a int) bool { return a > 0 }).
        Filter(func(a int) bool { return a%2 == 0 }).
        Map(func(a int) int { return a / 2 }).
        Reduce(func(a1, a2 int) int { return a1 + a2 })

fmt.Println(r)
// Output: 6

We start with a provided Sequence(slice) and then we apply multiple transformations: the first Filter returns only positive elements [1 ,2 ,3, 4, 5 ,6], the second Filter further narrows them to return only even numbers [2, 4, 6], then we transform each element with Map operation dividing each element by 2 to get [1, 2, 3], and then we sum these elements with Reduce to get the result 6.

The problem with this chain of operations is that this expression creates a new intermediate Sequence eagerly after each operation, until the final result is produced by Reduce operation. But, ideally, we instead want to just describe successive transformations without evaluating intermediate results, because creating intermediate results could be expensive for large data structures. We want to keep the clean structure of chained operations while avoiding the overhead of growing time and space complexity.

In order to achieve it, we want our strict Sequence to become a Lazy Sequence where intermediate Transformations (Filter, Map...) become "Lazy" operations that describe transformations rather than actually apply them. Something that could look like that:

r := Seq[int]{-2, -1, 0, 1, 2, 3, 4, 5, 6}.Lazy().
        Filter(func(a int) bool { return a > 0 }).
        Filter(func(a int) bool { return a%2 == 0 }).
        Map(func(a int) int { return a / 2 }).
        Reduce(func(a1, a2 int) int { return a1 + a2 })

fmt.Println(r)
// Output: 6

The only syntax difference that we want to have is a Lazy() method call that converts our strict Sequence into a lazy one, while all further operations are exactly the same. But how do we achieve that?

One approach to do it is to use Iterators. When we call the Lazy() method above, we get a Lazy Sequence structure which has a simple iterator inside (SeqIterator implements Iterator interface):

type Iterator[A any] interface {
    HasMore() bool
    Next() A    
 }

 type SeqIterator[A any] struct {
     seq          Seq[A]
     currentIndex int
 }

 func (iterator *SeqIterator[A]) HasMore() bool {
     return  iterator.currentIndex < len(iterator.seq)
 } 

 func (iterator *SeqIterator[A]) Next() A {
     current := iterator.seq[iterator.currentIndex]
     iterator.currentIndex = iterator.currentIndex + 1
     return current
 }

And each next transformation just transforms one input iterator into another one, which also implements Iterator interface. For example, calling Map transformation would return a structure with a new iterator inside (which also implements the same Iterator interface):

type MapIterator[A, B any] struct {
    inputIterator Iterator[A]
    mapF          func(A) B
}

func (iterator *MapIterator[A, B]) HasMore() bool {
    return  iterator.inputIterator.HasMore()
} 

func (iterator *MapIterator[A, B]) Next() B {
    return iterator.mapF(iterator.inputIterator.Next())
}

In other words, no real transformation happens during the Map call - we just produce a new iterator which describes a provided Map transformation on top of the underlying input iterator. Similarly, all other "Lazy" transformations are applied until we get to the last materializing Reduce call, that actually evaluates the whole chain of computations. The Reduce method uses HasMore() and Next() methods of the iterator to traverse it in one go, where all provided transformations are applied along the way during the traversal.

Apart from the mentioned above Map transformation, many more could be implemented for our Lazy Sequence using the same technique based on iterators. We can implement it for FlatMap, Filter, Zip and other common functional programming operations. And, similarly, each such operation would just describe an actual transformation we want to apply on top of another underlying iterator. When our chain of computations is fully described, we can actually execute it by calling some materializing action like Reduce, Fold etc. that would actually produce a meaningful result in 1 iteration, without using intermediate Sequences.

Conclusion

Applying some lazy evaluation techniques could sometimes be useful even when there is no direct built-in support for the laziness in the language. Using Thunks or Lazy Transformations patterns could help to apply laziness to solve some practical problems. If you are interested, you are welcomed to follow up on this topic and check out a functional programming library for Go that I develop github.com/ialekseev/go4fun. And join if you like, of course! Thanks for reading!