--- title: "Designing decorators" output: litedown::html_format vignette: > %\VignetteIndexEntry{Designing decorators} %\VignetteEngine{litedown::vignette} %\VignetteEncoding{UTF-8} --- ```{r setup} 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. ```r 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: ```{r state-bad} counter_bad := decorator(function(f, ...) { n <- 0 n <- n + 1 cat("call", n, "\n") f(...) }) bad <- counter_bad %@% add_one invisible(bad(1)) invisible(bad(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: ```{r state-good} 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)) invisible(first(1)) invisible(second(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: ```r 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: ```{r context-names} 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 fn_b <- at_call %@% add_one invisible(fn_b(1)) alias <- fn_b invisible(alias(1)) invisible(lapply(1, fn_b)) ``` 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 `""`: ```{r anonymous-setup} setup_name := decorator(function(f) { name <- decorator_context()$name function(...) { cat("->", name, "\n") f(...) } }) anon <- setup_name %@% function(x) x + 1 invisible(anon(1)) ``` 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: ```{r anonymous-lazy} 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)) ``` 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 `""`. If that is the first call, it is the name you keep: ```{r anonymous-weakness} fn := traced %@% function(x) x invisible(lapply(1, fn)) invisible(fn(1)) ``` 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: ```{r anonymous-name} 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)) invisible(lapply(1, 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. ```{r anonymous-walrus} add_two := setup_name_again %@% function(x) x + 2 invisible(add_two(1)) #> -> add_two invisible(lapply(1, 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: ```{r context-outside, error = TRUE} decorator_context() ``` ### 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: ```{r visible} quiet <- function() invisible("done") forwards := decorator(function(f, ...) f(...)) assigns := decorator(function(f, ...) { result <- f(...) result }) (forwards %@% quiet)() (assigns %@% quiet)() ``` If the decorator has to do something after the call and still return the result, `withVisible()` keeps the visibility: ```{r withvisible} 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: ```{r recursion} 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) n_calls ``` 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: - `f` inside the factory still has the original signature, so use `formals(f)` to build the returned function, and forward its arguments by name. - Quote symbols and calls you embed as values, or they are evaluated when the decorated function is called. - S3, S4 and S7 methods must match the signature of their generic, so only change a method's trailing arguments. 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: ```{r order} 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) ``` 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: ```{r errors, error = TRUE} bad_dec := decorator(function(f, ...) { stop("oops") f(...) }) plain := decorator(function(f, ...) f(...)) (bad_dec %@% add_one)(1) (plain %@% function(x) stop("inner"))(1) ``` 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: ```{r signature, error = TRUE} # The function to decorate must be the first argument. decorator(function(...) 1) # It receives the function to decorate, so it can't have a default. decorator(function(bob = "yo", f) f) # In call style, `...` must come straight after it. decorator(function(f, x, ...) 1) # A factory must return a function. not_a_function <- decorator(function(f) 1) not_a_function %@% add_one ``` ## A checklist Before you publish a decorator: - Does it need to remember something between calls? Use a factory. - Is there setup work that does not depend on the arguments? Do it in the factory, not on every call. - Does it return `f(...)` directly, or use `withVisible()`, so `invisible()` results stay invisible? - Does it call `decorator_context()` per call? If you only need a label, call it once in a factory, and for anonymous functions on the first call. - Would recursion or stacking change what it does? - Does it have a `name`, so errors and `decorators()` read well?