Changing a function’s signature

library(adorn)

By default a decorated function keeps the formal arguments of the original, so formals(), argument matching, defaults and missing() all behave as before. That is what you want for timed or retry, but it rules out decorators whose whole point is to change the signature: fixing some arguments in advance (partial application), currying, or adding an argument.

A factory-style decorator can opt in with formals = "replace". The decorated function then takes its formals from the function the factory returns, instead of from f:

two_to := decorator(
  function(f) function(y) f(2, y),
  formals = "replace"
)

pow := two_to %@% function(x, y) x^y
formals(pow)
#> $y
#> 
#> 
pow(3)
#> [1] 8

The examples use :=, which adorn re-exports from S7. It assigns the result and passes the variable’s name as name: logged := decorator(...) names the decorator, and pow := logged %@% f names the function, which is what decorator_context()$name reports even for an anonymous f.

adorn ships no partial-application decorator. This vignette builds one, and a few others, with only the public API.

What you take on

With the default, formals = "keep", adorn guarantees that argument matching and missing() work exactly as they do for the original. With "replace", that guarantee passes to you: the decorated function behaves like the function you return. Three things stay the same:

It only applies to factory style. A call-style wrapper is fixed to function(f, ..., params) and has no returned function, so decorator() refuses the combination.

partial

partial fixes some arguments by name and leaves the rest. It reads the formals of f, drops the fixed ones, and builds a function with what is left that calls f with the fixed values plus the remaining arguments forwarded.

partial := decorator(
  function(f, .fixed = list()) {
    fmls <- formals(f)
    unknown <- setdiff(names(.fixed), names(fmls))
    if (length(unknown) > 0L && !"..." %in% names(fmls)) {
      stop("Not arguments of `f`: ", paste(unknown, collapse = ", "), call. = FALSE)
    }
    keep <- fmls[setdiff(names(fmls), names(.fixed))]
    # Forward each remaining argument by name; `...` is forwarded unnamed.
    fwd <- lapply(names(keep), as.name)
    names(fwd) <- ifelse(names(keep) == "...", "", names(keep))
    # Quote symbols and calls so they are passed as values, not evaluated.
    fixed <- lapply(.fixed, function(v) {
      if (is.language(v)) call("quote", v) else v
    })
    as.function(c(keep, list(as.call(c(list(f), fixed, fwd)))))
  },
  formals = "replace"
)

pow <- function(x, y) x^y
two_to := partial(.fixed = list(x = 2)) %@% pow
square := partial(.fixed = list(y = 2)) %@% pow

names(formals(two_to))
#> [1] "y"
two_to(3)
#> [1] 8
square(4)
#> [1] 16

Fixed arguments can be leading or trailing, and the remaining arguments keep their defaults and missing() behaviour:

describe <- function(x, sep = "-", suffix) {
  paste0(x, sep, if (missing(suffix)) "none" else suffix)
}
tagged := partial(.fixed = list(x = "id")) %@% describe
tagged()
#> [1] "id-none"
tagged(suffix = "7")
#> [1] "id-7"

If f has ..., a fixed name that is not a formal is passed on through the dots, which is handy for forwarding options to the function the dots go to. Without ... it is an error, because it is almost certainly a typo:

joined := partial(.fixed = list(sep = "-")) %@% function(...) paste(...)
joined("a", "b")
#> [1] "a-b"
partial(.fixed = list(z = 1)) %@% pow
#> Error: Not arguments of `f`: z

Stacking

Every layer sees the signature of the layer below it, so partial stacks with any other decorator in either order:

logged := decorator(function(f, ...) {
  cat("calling with", length(list(...)), "argument(s)\n")
  f(...)
})

a := logged %@% partial(.fixed = list(x = 2)) %@% pow
b := partial(.fixed = list(x = 2)) %@% logged %@% pow

a(3)
#> calling with 1 argument(s)
#> [1] 8
b(3)
#> calling with 2 argument(s)
#> [1] 8
decorators(a)
#> [1] "logged"  "partial"
decorators(b)
#> [1] "partial" "logged" 

In a, logged sees the reduced signature and one argument. In b, logged is applied to the full pow, and partial is applied on top.

undecorate() still gives back the original, with all its arguments:

names(formals(undecorate(a)))
#> [1] "x" "y"

curry

A decorator can return any signature it likes. curry turns a function of several arguments into a chain of one-argument functions, each returning the next, until it has them all. Each step is given the real name of the argument it collects, so the formals stay meaningful at every stage:

curry := decorator(
  function(f) {
    # `f` still has the original formals here, so they say what to collect.
    args <- setdiff(names(formals(f)), "...")
    step <- function(done) {
      arg <- args[[length(done) + 1L]]
      last <- length(done) + 1L == length(args)
      next_fn <- if (last) function(all) do.call(f, all) else step
      # The body calls `next_fn` with everything collected so far plus this
      # argument. Both are embedded as values, not looked up by name, so an
      # argument called `done` or `f` cannot shadow anything.
      value <- as.call(c(list(as.name("list")), setNames(list(as.name(arg)), arg)))
      body <- as.call(list(next_fn, call("c", done, value)))
      as.function(c(setNames(alist(x = ), arg), list(body)), envir = baseenv())
    }
    step(list())
  },
  formals = "replace"
)

box <- function(width, height, depth) width * height * depth

volume := curry %@% box
volume(2)(3)(4)
#> [1] 24

The decorated function takes only the first argument, and each step takes the next one:

names(formals(volume))
#> [1] "width"
names(formals(volume(2)))
#> [1] "height"

A partly applied curry is an ordinary function, so it can be reused:

two_wide <- volume(2)
two_wide(3)(4)
#> [1] 24
two_wide(5)(5)
#> [1] 50

curry stacks like any other decorator. Here partial fixes depth first, so only width and height are left to collect:

area_of_slab := curry %@% partial(.fixed = list(depth = 10)) %@% box
names(formals(area_of_slab))
#> [1] "width"
area_of_slab(2)(3)
#> [1] 60

This curry always collects every argument, so defaults on the original are not used. Fix an argument with partial first if you want a default.

Adding an argument

The returned function may have more arguments than f, too:

with_scale := decorator(
  function(f) function(x, scale = 10) f(x) * scale,
  formals = "replace"
)

double_it := with_scale %@% function(x) x
double_it(2)
#> [1] 20
double_it(2, scale = 1)
#> [1] 2

Decorating methods

formals = "replace" works with decorate() for plain functions and R6 methods, where the method’s signature is free:

library(R6)
Account <- R6Class("Account",
  public = list(
    balance = 100,
    deposit = function(amount, fee) self$balance + amount - fee
  )
)
decorate("deposit", partial(.fixed = list(fee = 1)), class = Account)

Account$new()$deposit(50)
#> [1] 149

Take care with S3, S4 and S7 methods. They have to match the signature of their generic, so fixing a leading argument breaks dispatch, and S7 checks the method’s formals against the generic’s when you register it. Fixing only trailing arguments, which the generic does not use, is the safe case. See vignette("classes").

What it costs

formals = "replace" changes nothing about how a decorated call is made. The extra work is done once, when the function is decorated. A call to a partial adds one function call: the one your returned function makes to f.