library(garnish)
Many resources come as a pair: something you acquire, and something you must
release even if the code in between fails. Temporary directories, database
connections, locks, changed options, open sinks. Python has with and
@contextlib.contextmanager for this. contextmanager is the R version, and
this vignette explains what it gives you over on.exit() and
withr, because the honest answer is “less than you
might hope, and more than it first looks”.
on.exit()on.exit() runs when the function it is written in returns. That is exactly
wrong for a helper that acquires a resource for its caller:
make_tempdir <- function() {
d <- tempfile()
dir.create(d)
on.exit(unlink(d, recursive = TRUE))
d
}
dir.exists(make_tempdir())
#> [1] FALSE
The directory is gone before the caller sees it. So the acquire and release lines get copied into every function that needs the resource, and forgetting the second one leaks.
withr solves this with defer(), which attaches cleanup to another function’s
frame. The pattern is a local_ function, whose cleanup runs when the calling
function exits, and often a with_ twin for a scoped block:
local_tempdir_withr <- function(.local_envir = parent.frame()) {
d <- tempfile()
dir.create(d)
withr::defer(unlink(d, recursive = TRUE), envir = .local_envir)
d
}
f <- function() {
d <- local_tempdir_withr()
dir.exists(d)
}
c(inside = f())
#> inside
#> TRUE
.local_envir = parent.frame() is the whole trick. A default argument is
evaluated in the function it belongs to, so parent.frame() there means “the
function that called me”. Pass that frame to defer() and the cleanup waits for
the caller.
If this is all you need, use withr. contextmanager is built on defer().
contextmanager addsYou write the setup once, calling teardown() straight after each acquire, and
return the resource. R has no yield, so this is the closest equivalent:
local_tempdir := contextmanager %@% function(pattern = "tmp") {
path <- tempfile(pattern)
dir.create(path)
teardown(unlink(path, recursive = TRUE))
path
}
That single definition works three ways. Called inside a function, it cleans up when that function exits:
f <- function() {
d <- local_tempdir()
writeLines("hi", file.path(d, "x.txt"))
d
}
d <- f()
dir.exists(d)
#> [1] FALSE
with_context() scopes it to a block and hands the value to a function:
with_context(local_tempdir(), function(d) list.files(d))
#> character(0)
as_decorator() runs a whole function inside the context:
g := as_decorator(local_tempdir) %@% function() "ran inside the context"
g()
#> [1] "ran inside the context"
None of those is hard to write by hand for one resource. The advantage appears when there are several.
Take a fake database API with a connection, and a scratch table that only makes sense on a connection. We want to be able to use the scratch table in all three ways.
connect <- function(name) {
cat(" connect", name, "\n")
list(name = name)
}
disconnect <- function(con) cat(" disconnect", con$name, "\n")
create_table <- function(con, t) cat(" create", t, "\n")
drop_table <- function(con, t) cat(" drop", t, "\n")
local_con <- function(name, .local_envir = parent.frame()) {
con <- connect(name)
withr::defer(disconnect(con), envir = .local_envir)
con
}
local_scratch <- function(name, .local_envir = parent.frame()) {
con <- local_con(name, .local_envir)
create_table(con, "scratch")
withr::defer(drop_table(con, "scratch"), envir = .local_envir)
con
}
# The scoped form is a second copy of the same logic.
with_scratch <- function(name, code) {
con <- connect(name)
on.exit(disconnect(con))
create_table(con, "scratch")
on.exit(drop_table(con, "scratch"), add = TRUE, after = FALSE)
code(con)
}
Things to get right: pass .local_envir on to the inner helper, or the
connection closes as soon as local_scratch() returns. Write after = FALSE, or
the connection closes before the table is dropped. And keep the local_ and
with_ versions in step as the setup changes.
f <- function() {
con <- local_scratch("db")
cat(" using", con$name, "\n")
}
f()
#> connect db
#> create scratch
#> using db
#> drop scratch
#> disconnect db
contextmanagerlocal_con2 := contextmanager %@% function(name) {
con <- connect(name)
teardown(disconnect(con))
con
}
local_scratch2 := contextmanager %@% function(name) {
con <- local_con2(name)
create_table(con, "scratch")
teardown(drop_table(con, "scratch"))
con
}
The same output, from half the code:
f2 <- function() {
con <- local_scratch2("db")
cat(" using", con$name, "\n")
}
f2()
#> connect db
#> create scratch
#> using db
#> drop scratch
#> disconnect db
And the other two forms come from the same definition:
with_context(local_scratch2("db"), function(con) cat(" using", con$name, "\n"))
#> connect db
#> create scratch
#> using db
#> drop scratch
#> disconnect db
job := as_decorator(local_scratch2, "db") %@% function() cat(" running job\n")
job()
#> connect db
#> create scratch
#> running job
#> drop scratch
#> disconnect db
What did the work:
after = FALSE.local_con2() called from inside another context’s
setup is cleaned up with the outer context, not when the outer setup returns.
You don’t pass .local_envir along.con is an ordinary local variable
that teardown() can see. withr’s own generators, withr::with_() and
withr::local_(), take set and reset as two separate functions, so
anything shared between them has to be passed through the old value.on.exit() in a single function. Keep on.exit().local_ form. Writing defer(..., envir = .local_envir)
yourself is about as short, and depends on one fewer thing.withr::with_x(value, code) takes the code
itself. with_context() takes a function, which is a little clumsier.The best fit is a package with several acquire/release helpers, some built on others, where callers want more than one style.
If setup fails halfway, what was registered still runs.
local_partial := contextmanager %@% function() {
teardown(cat(" released the first thing\n"))
stop("second step failed")
}
try(with_context(local_partial(), identity), silent = TRUE)
#> released the first thing
The cleanup runs when the caller exits. If the caller catches the error itself
with tryCatch() and carries on, cleanup waits until that function returns.
There is no way to see the error in a teardown. Python’s generator can catch
an exception at the yield. defer() cannot, so a context cannot choose to
commit on success and roll back on failure. Handle that in the code that calls
the context, with tryCatch().
Why not a generator? coro::generator() has a real yield, which makes it
tempting. But coro does not support tryCatch(finally = ) around a yield, so
a generator cannot guarantee its teardown runs. teardown() needs nothing but
defer().
Top level. Calling a context at the console works. Cleanup is run by withr when the session ends.
contextmanager replaces the function’s signature with
function(..., .local_envir = <caller>) and forwards ... to your setup. When
you call teardown(expr), it searches the call stack for the nearest running
context manager, reads that call’s .local_envir, and hands the expression to
withr::defer() for that frame. Because the call stack is the record of which
contexts are running, there is no global state to keep in step.