How to Loop Over and Handle the ... Parameter in dplyr Functions
Programming with dplyr and the tidyverse is incredibly powerful, but transitioning from interactive analysis to writing reusable functions can be tricky. A common challenge is handling the dot-dot-dot (...) parameter when you need to loop over individual variables or access them sequentially.
While the curly-curly operator {{ }} works beautifully for passing a single variable, it doesn't allow you to slice, index, or loop over multiple arguments passed via .... In this article, we will show you how to capture, inspect, and iterate over ... arguments using the rlang package.
The Solution: Capturing Arguments with rlang::enquos()
To loop over the arguments in ..., you need to capture them as a list of quosures (quoted expressions coupled with an environment). The rlang::enquos() function does exactly this.
Once captured in a list, you can loop through them using a standard for loop or functional programming tools like purrr::walk() or lapply().
Implementation Example
Here is how you can write the function to accept unquoted column names, loop over them, group by each column sequentially, and create a new mean column:
library(dplyr)
library(rlang)
myfunc <- function(data, ...) {
# 1. Capture the dots as a list of quosures
cols <- enquos(...)
# 2. Loop through each captured column
for (i in seq_along(cols)) {
col_quosure <- cols[[i]]
# Convert the quosure to a string to construct the new column name
col_name <- as_name(col_quosure)
new_col_name <- paste0(col_name, "_mean")
# 3. Use bang-bang (!!) and the walrus operator (:=) to evaluate dynamically
data <- data |>
group_by(!!col_quosure) |>
mutate(!!new_col_name := mean(!!col_quosure, na.rm = TRUE)) |>
ungroup()
}
return(data)
}
You can now call this function exactly as desired, using unquoted column names:
# Test the function with the iris dataset
iris |>
myfunc(Sepal.Width, Sepal.Length) |>
head()
How It Works Under the Hood
enquos(...): This captures all the arguments passed to...without evaluating them immediately. It returns a named list of quosures.as_name(col_quosure): This converts a single quosure (likeSepal.Width) into a plain string ("Sepal.Width"). This is essential for programmatically creating new column names usingpaste0().!!(Bang-Bang): The injection operator tellsdplyrto evaluate the quosure immediately in the context of the data frame rather than looking for a literal variable named "col_quosure".:=(Walrus Operator): Standard R assignment (=) does not allow LHS (left-hand side) evaluation. The:=operator allows you to use a dynamically generated string on the left side of your assignment.
Alternative: Converting Quosures to Strings First
If you prefer using dplyr's across() and tidyselect helpers like all_of(), you can convert the captured quosures into a character vector first. This makes the loop body cleaner and avoids using !! inside the verbs:
myfunc_tidyselect <- function(data, ...) {
# Capture and convert all arguments to a character vector
cols <- sapply(enquos(...), as_name)
for (col in cols) {
new_col_name <- paste0(col, "_mean")
data <- data |>
group_by(across(all_of(col))) |>
mutate(!!new_col_name := mean(.data[[col]], na.rm = TRUE)) |>
ungroup()
}
return(data)
}
Both approaches yield identical results, but the across(all_of()) method is often preferred in modern dplyr development as it aligns closely with tidyselect best practices.