library(adorn)
add_one <- function(x) x + 1
vignette("writing-decorators") shows decorators that work. This vignette is
about writing ones that keep working: how to choose between the two styles,
what a decorated call costs, and the mistakes that are easy to make. Every
example below is run when the vignette is built, so the output shown is what you
will see.
decorator() looks at the signature of the function you give it.
| Call style | Factory style | |
|---|---|---|
| Signature | function(f, ..., options) |
function(f, options) |
| The body runs | on every call of the decorated function | once, when the function is decorated |
| It returns | the result of the call | a function that replaces f |
| State | none; locals are new each call | one set per decorated function |
| Good for | logging, timing, argument checks, retries | caching, counters, run-once, setup work |
The rule of thumb is that if the decorator needs to remember anything between calls, or has setup work that does not depend on the arguments, it is a factory. Otherwise a call-style decorator is shorter.
adorn adds roughly a microsecond to each call, and about the same again for each decorator you stack. A plain function call takes about a tenth of that. The exact numbers depend on your machine, so measure your own.
null_dec <- decorator(function(f, ...) f(...))
fast <- function(x) x + 1
decorated <- null_dec %@% fast
bench::mark(raw = fast(5), decorated = decorated(5))
For a function that does real work, such as reading a file, calling an API or fitting a model, that overhead is not measurable. It only matters for functions that finish in a few microseconds and are called millions of times. In that case, the cost of your own wrapper code will usually dominate what adorn adds, and that is the part you control.
A call-style body runs again on every call, so anything it creates is thrown away. This counter never gets past 1:
counter_bad := decorator(function(f, ...) {
n <- 0
n <- n + 1
cat("call", n, "\n")
f(...)
})
bad <- counter_bad %@% add_one
invisible(bad(1))
#> call 1
invisible(bad(1))
#> call 1
Make it a factory. The body now runs once per decorated function, and the
function it returns closes over n. Each decorated function gets its own copy:
counter := decorator(function(f) {
n <- 0
function(...) {
n <<- n + 1
cat("call", n, "\n")
f(...)
}
})
first <- counter %@% add_one
second <- counter %@% add_one
invisible(first(1))
#> call 1
invisible(first(1))
#> call 2
invisible(second(1))
#> call 1
In a call-style decorator, anything before f(...) runs on every call. If it
does not depend on the arguments, move it into a factory. memoize in
vignette("writing-decorators") does this: it hashes the function once, then
each call only hashes its arguments.
The same applies to decorator_context(). It looks at the call stack, which is
much more expensive than the rest of a decorator, and the cost grows with each
stacked decorator, because each level looks outward through the levels above it.
Asking for the context on every call, in four stacked decorators, costs several
times as much as the decorators themselves:
per_call := decorator(function(f, ...) {
name <- decorator_context()$name
f(...)
})
once := decorator(function(f) {
name <- decorator_context()$name
function(...) f(...)
})
lvl4_per_call <- per_call %@% per_call %@% per_call %@% per_call %@% add_one
lvl4_once <- once %@% once %@% once %@% once %@% add_one
bench::mark(per_call = lvl4_per_call(5), once = lvl4_once(5), check = FALSE)
On the machine this was written on, that was about 35µs per call against about 5µs. Both are fast in absolute terms, so this only matters in a hot loop.
The two do not return the same name, so which one you want depends on what the
name is for. In a factory, decorator_context() runs while the function is
being decorated, before any call exists, so the name is the one the function was
bound to at that point. In a call-style decorator it is the name the function
was called by:
at_setup := decorator(function(f) {
cat("setup sees:", decorator_context()$name, "\n")
function(...) f(...)
})
at_call := decorator(function(f, ...) {
cat("call sees: ", decorator_context()$name, "\n")
f(...)
})
fn_a <- at_setup %@% add_one
#> setup sees: add_one
fn_b <- at_call %@% add_one
invisible(fn_b(1))
#> call sees: fn_b
alias <- fn_b
invisible(alias(1))
#> call sees: alias
invisible(lapply(1, fn_b))
#> call sees: add_one
The last line shows the fallback. When there is no usable name in the call, as
with FUN inside lapply(), you get the name the function was decorated under.
If you want a name that follows each call, such as for logging, use call style
and accept the cost. If you just want a label that identifies the function, ask
once in a factory.
A function with no name has nothing to report at setup. %@% only sees the
function, not the variable you assign the result to, so a factory that reads the
name while it is being decorated gets "<anonymous>":
setup_name := decorator(function(f) {
name <- decorator_context()$name
function(...) {
cat("->", name, "\n")
f(...)
}
})
anon <- setup_name %@% function(x) x + 1
invisible(anon(1))
#> -> <anonymous>
The name exists once the function is called, so ask for it on the first call and keep it. This costs one stack lookup, once, and nothing after that:
traced := decorator(function(f) {
name <- NULL
function(...) {
if (is.null(name)) {
name <<- decorator_context()$name
}
cat("->", name, "\n")
f(...)
}
})
plus_one := traced %@% function(x) x + 1
invisible(plus_one(1))
#> -> plus_one
This has two limits. The first call decides the name, so if the function is later
called through an alias, it keeps the original name. And a call that carries no
name, such as FUN inside lapply(), or do.call() with the function itself,
gives "<anonymous>". If that is the first call, it is the name you keep:
fn := traced %@% function(x) x
invisible(lapply(1, fn))
#> -> fn
invisible(fn(1))
#> -> fn
If you need the right name in those cases, call decorator_context() on every
call, as in call style, and accept the cost.
If you can name the function when you decorate it, the problem goes away. %@%
takes a name argument, so the name is known at setup:
setup_name_again := decorator(function(f) {
name <- decorator_context()$name
function(...) {
cat("->", name, "\n")
f(...)
}
})
add_three <- `%@%`(setup_name_again, function(x) x + 3, name = "add_three")
invisible(add_three(1))
#> -> add_three
invisible(lapply(1, add_three))
#> -> add_three
adorn re-exports S7’s := operator, which fills name in from the left-hand
side, so you do not have to write it out.
add_two := setup_name_again %@% function(x) x + 2
invisible(add_two(1))
#> -> add_two
#> -> add_two
invisible(lapply(1, add_two))
#> -> add_two
#> -> add_two
:= only works with a plain variable on the left, so it does not help for
obj$method := ...; pass name directly there.
decorator_context() only works inside a decorator and gives an error anywhere
else:
decorator_context()
#> Error: `decorator_context()` can only be used inside a decorator.
invisible()Functions that return invisible() stay quiet when a call-style decorator
returns f(...) directly. Assigning the result first loses that, and the value
prints:
quiet <- function() invisible("done")
forwards := decorator(function(f, ...) f(...))
assigns := decorator(function(f, ...) {
result <- f(...)
result
})
(forwards %@% quiet)()
(assigns %@% quiet)()
#> [1] "done"
If the decorator has to do something after the call and still return the result,
withVisible() keeps the visibility:
keeps := decorator(function(f, ...) {
result <- withVisible(f(...))
# ... work after the call goes here ...
if (result$visible) result$value else invisible(result$value)
})
(keeps %@% quiet)()
A decorated function that calls itself calls the decorated version, so the
decorator runs on every level of the recursion. That is what you want for
memoize, and not what you want for a decorator that does something visible,
such as timing:
n_calls <- 0
count := decorator(function(f, ...) {
n_calls <<- n_calls + 1
f(...)
})
fib <- count %@% function(n) if (n < 2) n else fib(n - 1) + fib(n - 2)
fib(5)
#> [1] 5
n_calls
#> [1] 15
One call to fib(5) ran the decorator 15 times. If you only want the outermost
call, decorate a small wrapper that calls an undecorated recursive function
instead.
formals = "replace"By default a decorated function keeps the formals of the original, and adorn
makes sure missing() and defaults work. A factory that sets
formals = "replace" gives that up: the decorated function has the formals
of the function it returns, and getting missing(), defaults and argument
matching right is the decorator’s job. Remember too that:
f inside the factory still has the original signature, so use formals(f)
to build the returned function, and forward its arguments by name.See vignette("partial-decorators").
In a %@% b %@% f, a is the outermost decorator. Its code before f(...)
runs first, and its code after runs last:
a := decorator(function(f, ...) {
cat("a in\n")
result <- f(...)
cat("a out\n")
result
})
b := decorator(function(f, ...) {
cat("b in\n")
result <- f(...)
cat("b out\n")
result
})
(a %@% b %@% add_one)(1)
#> a in
#> b in
#> b out
#> a out
#> [1] 2
When the order matters, such as retry inside timed against timed inside
retry, the first measures all the attempts and the second measures each one.
An error raised in a decorator’s own code is reported against the decorator,
with the call name(f, ...). An error from the decorated function keeps its own
call:
bad_dec := decorator(function(f, ...) {
stop("oops")
f(...)
})
plain := decorator(function(f, ...) f(...))
(bad_dec %@% add_one)(1)
#> Error in bad_dec(f, ...): oops
(plain %@% function(x) stop("inner"))(1)
#> Error in f(...): inner
The name comes from the variable the decorator was assigned to, so bad_dec
shows as bad_dec. If you write a decorator inline and want a stable name in
errors and in decorators(), pass name = to decorator().
decorator() catches for youdecorator() checks the signature when you define it, and a factory is checked
when it is applied:
# The function to decorate must be the first argument.
decorator(function(...) 1)
#> Error: `wrapper` must take the function to decorate as its first argument.
# It receives the function to decorate, so it can't have a default.
decorator(function(bob = "yo", f) f)
#> Error: The first argument of `wrapper` receives the function to decorate, so `bob` can't have a default. Put decorator parameters after it, e.g. `function(f, param = ...)`.
# In call style, `...` must come straight after it.
decorator(function(f, x, ...) 1)
#> Error: In a call-style decorator, `...` must directly follow the function argument.
# A factory must return a function.
not_a_function <- decorator(function(f) 1)
not_a_function %@% add_one
#> Error: Decorator `not_a_function` must return a function.
Before you publish a decorator:
f(...) directly, or use withVisible(), so invisible()
results stay invisible?decorator_context() per call? If you only need a label, call it
once in a factory, and for anonymous functions on the first call.name, so errors and decorators() read well?