Function fitting in Go

Gustavo Chaín - Jun 14 - - Dev Community

In Go, as in most programming languages, the return value of a function g() can be used as an argument of another function f().

package main

func g() int  { return 42 }
func f(n int) { println(n) }

func main() {
    f(g()) // prints `42`
}
Enter fullscreen mode Exit fullscreen mode

Now, what happens when g() returns more than one value, can we still do that?

Short answer is "yes, but"

Some Theory

The Go spec treats this as a special case:

Calls

As a special case, if the return values of a function or method g are equal in number and individually assignable to the parameters of another function or method f, then the call f(g(_parameters_of_g_)) will invoke f after binding the return values of g to the parameters of f in order.

Effectively this piece of code will work too.

package main

func g() (int, int) { return 0, 0 }
func f(int, int) {}

func main() {
    f(g())
}
Enter fullscreen mode Exit fullscreen mode

Both the number and type of arguments that a function returns MUST be equal (or fit) into the arguments received by a function, although there is nuance.

[…] If f has a final ... parameter, it is assigned the return values of g that remain after assignment of regular parameters.

This means that we can

package main

func g() (int, int, int) { return 1, 2, 3}
func f(a int, n ...int) {
    fmt.Printf("a: %d, n: %v", a, b)
}

func main() {
    f(g()) // prints `a: 1, n: [2, 3]`
}
Enter fullscreen mode Exit fullscreen mode

In the example above, g() returns a larger number values than the number of arguments received by f(). This will work a long as the types match.

It is important to note that f() cannot receive any other argument than those returned by g(), this make both function signatures to be linked, meaning that g()'s return values fit into f()'s

Practical Usage

There is a common Go idiom that leverages this property by defining a Must function. A Must function takes the return values of a function f() and panics when there is an error. For Must to work f() must return zero or more arguments followed by a final error.

Take for instance template.Must helper that works in conjunction with the Parse function family (ParseFS, ParseFiles, ParseGlob and so on), it will panic when the returned error is not nil.
This idiom can be found outside the standard library too like google's UUID, where NewRandom() fits in Must().

Although the examples presented use concrete types, making this generic should be trivial:

func Must[T any](t T, err error) {
    if err != nil {
        panic(err)
    }

    return t
}
Enter fullscreen mode Exit fullscreen mode

(Un)fortunately we cannot make this generic enough and let our helper handle an arbitrary number of arguments AND types arguments is returned, meaning that if func f() (string, string, error) we’d need to create a new helper with increased arity for every function returning an extra argument.

func Must2[T1, T2 any](t1 T1, t2 T2, err error) (T1, T2) {
    return t1, Must(t2, err)
}

// And we can keep going.
func Must3(T1, T2, T3 any)(t1 T1, t2 T2, t3 T3, err error) (T1, T2, T3) {
    return t1, t2, Must(t3, err)
}
Enter fullscreen mode Exit fullscreen mode

Wrapping up

So why is this useful?
Must is intended for use in variable initialization which usually involves some degree of error handling. Error handling in Go has always been a hot topic and there are many angles from which we can look at it, any technique (no matter how small it is) will make our understanding of the problem better. It is also important to notice that being idiomatic in Go is key to writing code that will be easy to maintain, so if there’s is an idiom for handling errors, let’s use it.

Further readings:

.