Contents

Functional Patterns in Go

Functional Patterns in Go

One morning I faced a familiar challenge – processing a list of data through multiple transformation steps. In Java, I would chain a stream of operations (filtermapcollect), but in Go the tools are different. Although Go is not a “functional” language per se, it does offer first-class functions and closures that enable functional-style designs.

As Eli Bendersky notes, Go has “many of the building blocks required to support functional programming” even if FP “isn’t a mainstream paradigm” in Go eli.thegreenplace.net. My goal was to leverage higher-order functions, function composition, and closures to build a clean, maintainable pipeline – while comparing how Java’s lambdas and streams handle similar tasks.

Embracing Higher-Order Functions

I started by writing helper functions like Filter and Map to mimic the behavior of Java Streams. In Go, functions are first-class citizens – you can pass them around, return them, and even define custom function typesgo.dev. For example, a simple filter for integers looks like this:

1
2
3
4
5
6
7
8
9
func FilterInts(nums []int, keep func(int) bool) []int {     
	var result []int     
	for _, n := range nums {
		if keep(n) {
			result = append(result, n)
		}
	}
	return result 
}

This FilterInts function takes a slice and a predicate function (func(int) bool), and returns a new slice of elements that satisfy the predicate. In action, I could write:

1
2
3
nums := []int{1, 2, 3, 4, 5, 6}
isEven := func(x int) bool { return x%2 == 0 }
evens := FilterInts(nums, isEven)  // [2, 4, 6]

Under the hood this is exactly what Java would do with numbers.stream().filter(n -> n%2 == 0)..., but in Go the syntax is more verbose. Thanks to Go’s type system, a generic Map function can be created using Go 1.18+ generics to transform slices:

1
2
3
4
5
6
7
func Map[T any, R any](slice []T, fn func(T) R) []R {
    out := make([]R, 0, len(slice))
    for _, v := range slice {
        out = append(out, fn(v))
    }
    return out
}

Here, Map[T any, R any] applies fn to each element. This parallels Java’s stream().map(...). Using it, I could compose:

1
2
squares := Map(evens, func(x int) int { return x * x })
// squares is [4, 16, 36]

(This was taknen from StackOverflow answer on how Map can be written with genericsstackoverflow.com. As the Go codewalk points out, “Go supports first-class functions, higher-order functions, … closures” and these features “support a functional programming style”go.dev.

 Key takeaway: Go’s higher-order functions let us abstract loops and use predicates or transformers much like Java streams, but we write those helpers ourselves 😥

Composing Functions into Pipelines

Next I wanted to chain transformations.

Function composition is idiomatic in FP: e.g. composing f ∘ g means applying g then f. Go has no special operator for composition 🤷 , but I can define a Compose helper:

1
2
3
4
5
func Compose(f, g func(int) int) func(int) int {
    return func(x int) int { 
        return f(g(x))
    }
}

Using Compose, I can build a new function from simpler ones:

1
2
3
4
inc := func(x int) int { return x + 1 }
sqr := func(x int) int { return x * x }
sqrThenInc := Compose(inc, sqr)
fmt.Println(sqrThenInc(3)) // prints 10 (i.e. inc(sqr(3)))

This manual composition mirrors what functional languages do with f(g(x)). I noticed, however, that error handling complicates things. In Go a function often returns (value, error), so composing those requires checking errors at each step, which quickly becomes verbose (as described by AslRousta medium.com). For example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func parseInt(s string) (int, error) { /*...*/ }
func double(x int) int { return x * 2 }

// Composing two functions with an error
func parseDouble(s string) (int, error) {
    x, err := parseInt(s)
    if err != nil {
        return 0, err
    }
    return double(x), nil
}

In Java ☕ streams this would be muuuuch smoother: you could write something like strings.stream().map(this::parseInt).map(x -> x*2), and any exception would propagate differently. Go’s design means each composition step must explicitly handle errors. I would call it a limitation - you want to express pipeline abstraction without such “friction”

A Pitfall of Closures and Loop Variables

As I refactored loops into functions and goroutines, in the past I often ran into the classic Go closure trap. For example:

1
2
3
4
5
6
vals := []string{"a", "b", "c"}
for _, v := range vals {
    go func() {
        fmt.Println(v)
    }()
}

This code doesn’t behave as expected: often it prints "c", "c", "c" instead of "a","b","c". The issue is that the closure captures the variable v, not its value at each iteration. All goroutines share the same v, which ends up as "c". The Go blog calls this “one of the most common Go mistakes”go.dev.

The fix is to  pass v as a parameter or re-bind it inside the loop:

1
2
3
4
5
for _, v := range vals {
    go func(x string) {
        fmt.Println(x)
    }(v) // pass v as argument
}

Now each closure gets its own copy x. (As a sidenote, Go 1.22 changed loop scoping to avoid this problem, see this blogpost)

This experience highlighted Go’s closure semantics. On the plus side, Go’s closures work as expected in other ways. The Effective Go docs remind us: “In Go, function literals are closures: the implementation makes sure the variables referred to by the function survive as long as they are active.”golang.org. In practice this means my anonymous functions reliably see the latest values of captured variables, except in loop scenarios like above.

Comparing to Java: Lambdas and Streams

As I’m a Java developer, I often think in terms of streams and lambdas. For example, given a list of numbers I might write in Java:

1
2
3
4
List<Integer> result = numbers.stream()
    .filter(n -> n % 2 == 0)         // lambda predicate
    .map(n -> n * n)                 // lambda mapping
    .collect(Collectors.toList());

Here Java 8’s lambdas treat functionality as method argument and the Stream API provides built-in pipeline operations .

Go has no direct equivalent of stream() or lambda syntax, but the patterns are achievable with our Filter/Map helpers and anonymous functions. In Java, the variables captured by lambdas must be effectively final (i.e. not reassigned), a restriction rooted in thread-safety concerns stackoverflow.com. Go has no final keyword – closures can capture any local variable. This means this is me who needs to think about avoiding unintended mutations or race conditions .

 In terms of immutability, Java lets you declare final variables and has immutable types, but Go has no built-in immutability . We rely on value semantics (passing copies) or discipline not to mutate shared data. For instance, Go’s const keyword is only for compile-time constants, not general immutability. This means some FP patterns (like persistent data structures) are harder in Go, and we often use short-lived slices or maps instead.

Concurrency and Avoiding Shared State

One advantage Go has is its model for safe concurrency. Functional programming prizes avoiding shared mutable state; interestingly, Go’s philosophy lines up: the Go authors sloganize  “Do not communicate by sharing memory; instead, share memory by communicating.”. In practice, I often set up pipelines with channels to pass data between goroutines, keeping each value isolated. For example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func gen(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

for n := range square(gen(1,2,3,4)) {
    fmt.Println(n)
}

This sets up two stages: one goroutine generates numbers and another squares them, passing values over channels. No shared memory is needed and no mutexes – so this is so called CSP-style concurrency. If I were to write it in Java, I would probably need a lot of synchronization (or I’d use proper data structure).

Conclusion: Go is Functionally Limited, Java is superior

Working through this problem, I found that Go can be used in a functional style, but it requires much more boilerplate than languages designed for FP:

  • it has first-class functions, higher-order functions, and closures built in
  • garbage collection means closures capturing variables don’t lead to dangling pointerseli.thegreenplace.net.
  • we can implement filtermapreduce, and pipelines ourselves (and even build generic versions
  • go’s concurrency model with channels naturally supports pipelines without shared state

 However:    * go lacks “syntactic sugar” for FP  * there are no built-in map or filter functions  * no lambda literal syntax (only anonymous func)  * there is no immutability guarantee.  * error handling in chained function composition is verbose   In the end, the journey taught me that idiomatic Go is often a blend: we can borrow FP patterns where they are helpful, but - for the sake of “idiomatic go” - we are better off when we stay explicit and simple.

By using higher-order functions and channel pipelines, I kept the code modular and testable. Compared to Java streams and lambdas, Go’s approach is rather manual and sometimes… a bit cumbersome, but also more transparent. As Bendersky summarizes, FP isn’t Go’s primary paradigm, yet its support for HOF and closures means we can express much of that style.

The result in the code was a clean, functional-like pipeline in Go that solved the problem effectively, where Go’s strengths were used and its constraints were avoided.

The result in my thinking 🧠 was even stronger conviction that Java ☕ is a much nicer and feature-rich language for implementing pipelines.

 

Sources: