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!