Designing decorators

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.

Choosing a style

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.

What a decorated call costs

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.

Pitfalls

State in a call-style decorator

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 

Work done on every call that could be done once

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.

Anonymous functions

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.

Losing 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)()

Recursion goes through the decorator every time

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.

Changing the signature with 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:

See vignette("partial-decorators").

Order of stacked 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.

Reading errors

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().

Mistakes decorator() catches for you

decorator() 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.

A checklist

Before you publish a decorator: