The splice operator !!!
implemented in dynamic dots
injects a list of arguments into a function call. It belongs to the
family of injection operators and provides the same
functionality as do.call()
.
The two main cases for splice injection are:
Turning a list of inputs into distinct arguments. This is especially useful with functions that take data in
...
, such asbase::rbind()
.Injecting defused expressions like symbolised column names.
For tidyverse APIs, this second case is no longer as useful since dplyr 1.0 and the
across()
operator.
Where does !!!
work?
!!!
does not work everywhere, you can only use it within certain
special functions:
Functions taking dynamic dots like
list2()
.Functions taking defused and data-masked arguments, which are dynamic by default.
Inside
inject()
.
Most tidyverse functions support !!!
out of the box. With base
functions you need to use inject()
to enable !!!
.
Using the operator out of context may lead to incorrect results, see What happens if I use injection operators out of context?.
Splicing a list of arguments
Take a function like base::rbind()
that takes data in ...
. This
sort of functions takes a variable number of arguments.
df1 <- data.frame(x = 1)
df2 <- data.frame(x = 2)
rbind(df1, df2)
#> x
#> 1 1
#> 2 2
Passing individual arguments is only possible for a fixed amount of
arguments. When the arguments are in a list whose length is
variable (and potentially very large), we need a programmatic
approach like the splicing syntax !!!
:
Because rbind()
is a base function we used inject()
to
explicitly enable !!!
. However, many functions implement dynamic dots with !!!
implicitly enabled out of the box.
tidyr::expand_grid(x = 1:2, y = c("a", "b"))
#> # A tibble: 4 x 2
#> x y
#> <int> <chr>
#> 1 1 a
#> 2 1 b
#> 3 2 a
#> 4 2 b
xs <- list(x = 1:2, y = c("a", "b"))
tidyr::expand_grid(!!!xs)
#> # A tibble: 4 x 2
#> x y
#> <int> <chr>
#> 1 1 a
#> 2 1 b
#> 3 2 a
#> 4 2 b
Note how the expanded grid has the right column names. That's because we spliced a named list. Splicing causes each name of the list to become an argument name.
tidyr::expand_grid(!!!set_names(xs, toupper))
#> # A tibble: 4 x 2
#> X Y
#> <int> <chr>
#> 1 1 a
#> 2 1 b
#> 3 2 a
#> 4 2 b
Splicing a list of expressions
Another usage for !!!
is to inject defused expressions into data-masked
dots. However this usage is no longer a common pattern for
programming with tidyverse functions and we recommend using other
patterns if possible.
First, instead of using the defuse-and-inject pattern with ...
, you can simply pass
them on as you normally would. These two expressions are completely
equivalent:
my_group_by <- function(.data, ...) {
.data %>% dplyr::group_by(!!!enquos(...))
}
# This equivalent syntax is preferred
my_group_by <- function(.data, ...) {
.data %>% dplyr::group_by(...)
}
Second, more complex applications such as transformation patterns can be solved with the across()
operation introduced in dplyr 1.0. Say you want to take the
mean()
of all expressions in ...
. Before across()
, you had to
defuse the ...
expressions, wrap them in a call to mean()
, and
inject them in summarise()
.
my_mean <- function(.data, ...) {
# Defuse dots and auto-name them
exprs <- enquos(..., .named = TRUE)
# Wrap the expressions in a call to `mean()`
exprs <- purrr::map(exprs, ~ call("mean", .x, na.rm = TRUE))
# Inject them
.data %>% dplyr::summarise(!!!exprs)
}
It is much easier to use across()
instead:
Performance of injected dots and dynamic dots
Take this dynamic dots function:
Because it takes dynamic dots you can splice with !!!
out of the
box.
n_args(1, 2)
#> [1] 2
n_args(!!!mtcars)
#> [1] 11
Equivalently you could enable !!!
explicitly with inject()
.
inject(n_args(!!!mtcars))
#> [1] 11
While the result is the same, what is going on under the hood is
completely different. list2()
is a dots collector that
special-cases !!!
arguments. On the other hand, inject()
operates on the language and creates a function call containing as
many arguments as there are elements in the spliced list. If you
supply a list of size 1e6, inject()
is creating one million
arguments before evaluation. This can be much slower.
xs <- rep(list(1), 1e6)
system.time(
n_args(!!!xs)
)
#> user system elapsed
#> 0.009 0.000 0.009
system.time(
inject(n_args(!!!xs))
)
#> user system elapsed
#> 0.445 0.012 0.457
The same issue occurs when functions taking dynamic dots are called
inside a data-masking function like dplyr::mutate()
. The
mechanism that enables !!!
injection in these arguments is the
same as in inject()
.