Python’s Context Managers are a useful feature which allow you to open and close resources cleanly.
with db.connect() as conn:
# ...
The idiomatic way of doing the same in Go is with the use of defer
statements.
conn, err := db.Connect()
if err != nil {
// ...
}
defer conn.Close()
This suffices except when you are unable to add the cleanup method
(Close
) to the retuned object (conn
).
An example of this that I run into often is the need to create a
temporary directory and deleting it afterwards. Let’s look at the
documentation for ioutil.TempDir
.
TempDir creates a new temporary directory […] and returns the path of the new directory. […] the caller’s responsibility to remove the directory when no longer needed.
Since TempDir
returns a string, adding a Close
or Delete
method is
not an option.
It would be easy for one to fall back to a Python-like pattern here.
// WithTempDir creates a temporary directory and calls the given
// function with the path to it. The directory is cleaned up when
// the function returns.
//
// The test is marked as failed if the temporary directory could
// not be created.
func WithTempDir(t *testing.T, f func(dir string)) {
dir, err := ioutil.TempDir("", "test")
if err != nil {
t.Fatalf("failed to create temporary directory: %v", err)
}
defer os.RemoveAll(dir)
f(dir)
})
Usage of the above function looks like so,
func TestFoo(t *testing.T) {
WithTempDir(t, func(dir string) {
// ...
})
}
1. Problem
With the context manager, each use requires nesting your code further. Your business logic will run off the screen if you need to use more context managers.
WithTempDir(t, func(dir string) {
WithTempFile(t, dir, func(file string) {
var mu sync.Mutex
WithLock(mu, func() {
// ...
})
})
})
State management becomes fragile because returning values requires
mutating variables in the outer scope.
The functions accepted by the With*
functions must have a specific
signature.
The inability to change the signature means that to send a value from the inner
function to the caller, you must mutate variables in the outer scope.
var result *User
WithTempDir(t, func(dir string) {
// ...
var err error
result, err = parseFile(filepath.Join(dir, "user"))
if err != nil {
// ...
}
})
As the number of variables and context managers increases, this code will become more unreadable and more prone to bugs.
Finally, you cannot dynamically nest calls.
If you need a list of N temporary directories where N is not fixed,
there is no good way to use WithTempDir
N times.
That is, the following is not possible.
WithTempDir(t, func(dir1 string) {
WithTempDir(t, func(dir2 string) {
WithTempDir(t, func(dir3 string) {
// ?
WithTempDir(t, func(dirN string) {
})
})
})
})
📝 Note
|
Python’s Context Managers solve this at the library level with
contextlib.ExitStack .
|
2. Solution
We want to be able to defer
cleanup functions but adding a Close()
or Delete()
method to string
is not possible.
How about returning an anonymous function that does the cleanup?
// CreateTempDir creates a temporary directory and returns the path
// to it, along with a function to clean up when the directory is
// no longer needed.
//
// The test is marked as failed if the temporary directory could
// not be created.
func CreateTempDir(t *testing.T) (dir string, cleanup func()) {
dir, err := ioutil.TempDir("", "test")
if err != nil {
t.Fatalf("failed to create temporary directory: %v", err)
}
return dir, func() {
os.RemoveAll(dir)
}
}
That looks better! Here is how you use it:
func TestFoo(t *testing.T) {
dir, cleanup := CreateTempDir(t)
defer cleanup()
// ...
}
We can call any number of similar functions without affecting readability of the business logic.
dir, cleanup := CreateTempDir(t)
defer cleanup()
file, cleanup := CreateTempFile(t, dir)
defer cleanup()
// ...
It’s easy to call them dynamically and defer
cleanup in a loop.
dirs := make([]string, N)
for i := 0; i < N; i++ {
dir, cleanup := CreateTempDir(t)
defer cleanup() (1)
dirs[i] = dir
}
-
Note that deferred calls run when the surrounding function returns, not when the scope ends. In other words, the temporary directories will be cleaned up not when a single
for
iteration ends, but when the function returns.