Engineering Blog

Ignoring how defer arguments are evaluated

In order to understand how arguments are processed using the defer keyword, let’s consider a specific example. Suppose we have a function that needs to execute two other functions, called foo and bar. Additionally, this function needs to handle some status information related to its execution. We can use the defer keyword to ensure that the status is properly managed after the foo and bar functions have been called.

  • StatusSuccess if both foo and bar return no errors
  • StatusErrorFoo if foo returns an error
  • StatusErrorBar if bar returns an error
func f() error {
var status string
defer notify(status)
defer incrementCounter(status)
if err := foo(); err != nil {
status = StatusErrorFoo
return err
} 
if err := bar(); err != nil {
status = StatusErrorBar
return err
} 
status = StatusSuccess
return nil
}

First, we declare a status variable. Then we defer the calls to notify and increment Counter using defer . Throughout this function, and depending on the execution path, we update the status accordingly.
However, if we give this function a try, we see that regardless of the execution path, notify and incrementCounter are always called with the same status: an empty string. How is this possible?

It’s important to understand that when you use defer to call a function, the arguments for that function are evaluated immediately, rather than being evaluated when the surrounding function returns. This can cause problems if you want to pass the current value of a variable as an argument to the deferred function. For example, if you have a function f that calls notify(status) and incrementCounter(status) using defer, and you want to pass the current value of status as an argument, the value of status will be evaluated at the point where the defer statement is used, rather than at the time the deferred function is actually called. This means that if status is an empty string at the time the defer statement is used, an empty string will be passed to the deferred functions when they are eventually called. The first solution is to pass a string pointer to the defer functions:

func f() error {
Passes a string
var status string
pointer to notify
defer notify(&status)
defer incrementCounter(&status)
if err := foo(); err != nil {
status = StatusErrorFoo
return err
}
if err := bar(); err != nil {
status = StatusErrorBar
return err
}
status = StatusSuccess
return nil
}

We keep updating the status depending on the cases, but now notify and increment Counter receive a string pointer. Why does this approach work?

This way, the deferred functions will receive the address of status, rather than the value of status, and they will be able to access the current value of status when they are eventually called. The status itself is modified throughout the function, but its address remains constant, regardless of the assignments

However, this solution requires changing the signatures of notify and incrementCounter to accept a pointer to a string, rather than a string itself. This may not always be possible, depending on the requirements of your code.

It is possible to use a closure in a “defer” statement as an alternative solution. A closure is a function without a name that has access to variables outside of its own code block. When a defer statement is called, the arguments in the function are immediately evaluated. However, the variables that the closure references will not be evaluated until the closure is executed, which occurs when the enclosing function returns.

Here is an example to clarify how defer closures work. A closure references two variables, one as a function argument and the second as a variable outside its body:

func main() {
i := 0
j := 0
defer func(i int) {
fmt.Println(i, j)
}(i)
i++
j++
}

Here, the closure uses i and j variables. i is passed as a function argument, so it’s evaluated immediately. Conversely, j references a variable outside of the closure body, so it’s evaluated when the closure is executed. If we run this example, it will print 0 1 . Therefore, we can use a closure to implement a new version of our function:

func f() error {
var status string
defer func() {
notify(status)
incrementCounter(status)
}()
}

In this case, the calls to both “notify” and “incrementCounter” are placed within a closure. The closure uses the “status” variable from outside its own code block. As a result, the value of “status” is not determined until the closure is executed, rather than when “defer” is called. This method is effective and does not require modifying the function signatures of “notify” and “incrementCounter”.

Conclusion

In summary, when we call defer on a function or method, the call’s arguments are evaluated immediately. If we want to mutate the arguments provided to defer afterward, we can use pointers or closures. Using pointers requires changing the signature of the two functions, which may not always be possible. So using closure does not require changing the signature of the two functions and arguments are evaluated once the closure is executed, not when we call defer.

References:

  • 100 Go Mistakes and how to avoid them, Teiva Harsanyi, Manning Publications Co
Previous Post
Next Post