Balancing Function Splitting and Performance in Go
A key aspect of maintainable code is extracting (reusable) logic into separate functions. However, before I haphazardly split my code into dozens of functions, I need a compelling reason. High cohesion and loose coupling are absolute priorities for me. Otherwise, a function can remain longer—unless it contains blocks that are truly reusable.
Unnecessary functions come at a cost. I’ll emphasize upfront that the impact is often negligible, but it’s important to remember that such structuring isn’t free. I’ve run a few experiments in Go to illustrate these effects.
Let’s examine a trivial Go program. It generates a random number for y
and assigns a value to x
based on y
’s
value:
func main() {
y := rand.Int()
x := "a"
if y == 3 {
x = "b"
}
fmt.Println(x)
}
A second variant uses a closure to assign x
:
func main() {
y := rand.Int()
x := func() string {
if y == 3 {
return "b"
}
return "a"
}()
fmt.Println(x)
}
The compiler optimizes this call and can inline the closure. The resulting assembly code is effectively identical—only a
NOPL
(No Operation) instruction is added in the closure version. Practically, this has no impact on the generated
code.
The third version introduces a standalone function, yet the difference remains minimal:
func set(y int) string {
if y == 3 {
return "b"
}
return "a"
}
func main() {
y := rand.Int()
x := set(y)
fmt.Println(x)
}
Here, the compiler inlines the function call as well. Unlike the closure, no NOPL
appears in the assembly, making the
code identical to the original version.
The difference becomes noticeable only when we force the compiler to keep the function separate:
//go:noinline
func set2(y int) string {
if y == 3 {
return "b"
}
return "a"
}
func main() {
y := rand.Int()
x := set2(y)
fmt.Println(x)
}
Now, the function call’s overhead is visible in the assembly code. Note that //go:noinline
is a directive the compiler
may still ignore.
Understanding what happens under the hood—and the potential consequences—is crucial. Only then can you make informed
decisions and evaluate results accurately. The true behavior is revealed by the output of
go build -gcflags="-m" main.go
, where hints like inlining call to set
confirm compiler optimizations.
It’s also worth noting that function calls involve stack management. For optimized code, inspecting the compiler output is indispensable.
A deliberate approach to splitting functions leads to clean, efficient code.
The complete Go and Assembly code referenced here is available in this GitHub Gist.