Functions and tidy evaluation
Lesson 8 — R Programming
Learning objectives
- Write functions with sensible arguments, defaults and return values
- Explain lazy evaluation and where it surprises you
- Use
{ },...and.datato write functions that take column names - Replace loops with
purrrmap functions and keep types safe - Validate arguments and fail with useful messages
When to write a function
The rule of three: if you have copy-pasted a block twice, make it a function. The real trigger is subtler — write a function as soon as you find yourself editing the same logic in two places and worrying you missed one.
# Repeated three times with different columns
adsl$age_cat <- cut(adsl$AGE, c(-Inf, 18, 65, Inf), labels = c("<18","18-64",">=65"))
adsl$weight_cat <- cut(adsl$WEIGHT, c(-Inf, 60, 90, Inf), labels = c("low","mid","high"))
# Once
categorise <- function(x, breaks, labels) {
cut(x, breaks = c(-Inf, breaks, Inf), labels = labels, right = FALSE)
}Anatomy
calculate_bmi <- function(weight, height, units = c("cm", "m")) {
units <- match.arg(units)
height_m <- switch(units, cm = height / 100, m = height)
weight / height_m^2
}
calculate_bmi(70, 175) #> 22.86
calculate_bmi(70, 1.75, "m") #> 22.86
calculate_bmi(70, 175, "inches")
#> Error in match.arg(units) : 'arg' should be one of "cm", "m"match.arg() with a character-vector default gives you validated choices and uses the first as the default. Use it whenever an argument has a fixed set of allowed values.
Return values
R returns the last evaluated expression. Use explicit return() only for early exits:
safe_divide <- function(x, y) {
if (y == 0) {
return(NA_real_) # early exit — explicit return is right here
}
x / y # normal path — no return() needed
}invisible() returns a value without printing it, which is what you want for functions called for side effects:
write_output <- function(data, path) {
readr::write_csv(data, path)
cli::cli_alert_success("Wrote {nrow(data)} rows to {.path {path}}")
invisible(data) # allows piping to continue
}
adsl |> write_output("out.csv") |> nrow()Lazy evaluation
Arguments are not evaluated until used:
f <- function(x, y) {
x * 2 # y is never touched
}
f(5, stop("boom"))
#> [1] 10 <- no error!This enables defaults that reference other arguments:
summarise_var <- function(x, label = deparse(substitute(x)), digits = 2) {
sprintf("%s: %.*f (%.*f)", label, digits, mean(x, na.rm = TRUE),
digits, sd(x, na.rm = TRUE))
}
age <- c(45, 52, 38)
summarise_var(age)
#> [1] "age: 45.00 (7.00)"It also causes a classic bug in loops that create functions:
fns <- list()
for (i in 1:3) {
fns[[i]] <- function() i
}
fns[[1]]()
#> [1] 3 <- all three see the final value of i
# Force evaluation
fns <- lapply(1:3, function(i) { force(i); function() i })
fns[[1]]()
#> [1] 1Functions that take column names
This is where R functions get awkward. dplyr uses data masking — you write filter(df, age > 65) not filter(df, df$age > 65). When you wrap that in a function, the column name needs to survive being passed as an argument.
# Does not work
summarise_by <- function(data, group_var, value_var) {
data |>
summarise(mean = mean(value_var), .by = group_var)
}
summarise_by(adsl, ARM, AGE)
#> Error: object 'ARM' not foundEmbracing with { }
summarise_by <- function(data, group_var, value_var) {
data |>
summarise(
n = n(),
mean = mean({{ value_var }}, na.rm = TRUE),
sd = sd({{ value_var }}, na.rm = TRUE),
.by = {{ group_var }}
)
}
summarise_by(adsl, ARM, AGE)
#> # A tibble: 3 x 4
#> ARM n mean sd
#> <chr> <int> <dbl> <dbl>
#> 1 Placebo 86 75.2 8.59Read { x } as “take what the user typed and use it here”. It works in any data-masking argument: filter(), mutate(), group_by(), arrange(), summarise(), aes().
Name the output column after the input with := and the glue syntax:
add_change <- function(data, value_var, baseline_var) {
data |>
mutate("{{ value_var }}_chg" := {{ value_var }} - {{ baseline_var }})
}
adlb |> add_change(AVAL, BASE)
#> # A tibble: ... with new column AVAL_chgStrings with .data
When the column name arrives as a character string — from a config file, a metadata spec, or a Shiny input — use the .data pronoun:
summarise_by_name <- function(data, group_col, value_col) {
data |>
summarise(
n = n(),
mean = mean(.data[[value_col]], na.rm = TRUE),
.by = all_of(group_col)
)
}
summarise_by_name(adsl, "ARM", "AGE").data[[x]] where x is a string; all_of(x) in selection contexts. This is the pattern for metadata-driven programming — see Metadata-driven programming.
Convert between the two worlds:
# String -> symbol
sym("AGE")
rlang::ensym(var)
# Symbol -> string
rlang::as_name(rlang::enquo(var))
rlang::englue("{{ var }}")Passing through ...
plot_endpoint <- function(data, param, ...) {
data |>
filter(PARAMCD == param) |>
ggplot(aes(AVISITN, AVAL, ...)) +
geom_line()
}
plot_endpoint(adlb, "ALT", colour = TRT01A, group = USUBJID)... forwards any number of named arguments. Two cautions: typos in argument names pass through silently, and you cannot document them individually. Use rlang::check_dots_used() to catch unused arguments.
Selection helpers
For arguments that select columns, use tidyselect:
round_columns <- function(data, cols, digits = 2) {
data |> mutate(across({{ cols }}, ~ round(.x, digits)))
}
adlb |> round_columns(c(AVAL, BASE, CHG))
adlb |> round_columns(where(is.numeric))
adlb |> round_columns(starts_with("A"))purrr instead of loops
library(purrr)
# map returns a list
map(1:3, ~ .x^2)
#> [[1]] 1
#> [[2]] 4
#> [[3]] 9
# Typed variants return atomic vectors and CHECK the type
map_dbl(1:3, ~ .x^2) #> 1 4 9
map_chr(letters[1:3], toupper) #> "A" "B" "C"
map_int(1:3, ~ .x * 2L)
map_lgl(1:3, ~ .x > 1)
map_dbl(1:3, ~ as.character(.x))
#> Error: Can't coerce from a string to a doubleThat error is the point. map_dbl() guarantees a numeric vector of the same length as the input; if any iteration returns something else you find out immediately rather than three functions later.
# Two inputs
map2_dbl(c(1, 2, 3), c(10, 20, 30), ~ .x * .y) #> 10 40 90
# Any number
pmap_chr(list(a = c("x","y"), b = 1:2), \(a, b) paste0(a, b))
# Side effects only, returns input invisibly
walk(files, ~ write_csv(read_csv(.x), fs::path_ext_set(.x, "clean.csv")))
# Combine results
map(files, read_csv) |> list_rbind()
map(files, read_csv) |> list_rbind(names_to = "source")Handling failures
safe_read <- possibly(read_csv, otherwise = NULL)
results <- map(files, safe_read) |> compact() # drop the NULLs
# Keep both result and error
out <- map(files, safely(read_csv))
errors <- out |> map("error") |> compact()
data <- out |> map("result") |> compact()possibly() and safely() turn an error into a value, which is what you want in a batch job that must process 40 files and report on the three that failed rather than stopping at the first.
Anonymous function syntax
map_dbl(x, function(v) mean(v, na.rm = TRUE)) # verbose
map_dbl(x, \(v) mean(v, na.rm = TRUE)) # base R 4.1+
map_dbl(x, ~ mean(.x, na.rm = TRUE)) # purrr formulaPrefer \(x) — it is base R, works everywhere, and named arguments make the code readable. The ~ .x form is shorter for one-liners.
Validating arguments
Fail early, and say what is wrong:
library(cli)
derive_bmi <- function(data, weight_var, height_var, height_units = c("cm", "m")) {
if (!is.data.frame(data)) {
cli_abort("{.arg data} must be a data frame, not {.obj_type_friendly {data}}.")
}
height_units <- match.arg(height_units)
w <- data |> dplyr::pull({{ weight_var }})
h <- data |> dplyr::pull({{ height_var }})
if (!is.numeric(w)) cli_abort("{.arg weight_var} must be numeric.")
if (!is.numeric(h)) cli_abort("{.arg height_var} must be numeric.")
if (any(h <= 0, na.rm = TRUE)) {
n_bad <- sum(h <= 0, na.rm = TRUE)
cli_abort(c(
"Height must be positive.",
"x" = "Found {n_bad} non-positive value{?s}.",
"i" = "Check the units — are these metres recorded as centimetres?"
))
}
h_m <- if (height_units == "cm") h / 100 else h
dplyr::mutate(data, BMI = w / h_m^2)
}cli_abort() gives you glue interpolation, pluralisation ({?s}), semantic markup ({.arg}, {.path}, {.val}) and a bulleted structure with x for the problem and i for the hint. It costs nothing extra and makes errors diagnosable by someone who did not write the function.
For quick internal checks, stopifnot() is fine:
stopifnot(
"data must be a data frame" = is.data.frame(data),
"weights must be positive" = all(w > 0, na.rm = TRUE)
)Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Bare column name in a function | “object not found” | { } |
String column name with { } |
Treated as a literal string | .data[[x]] / all_of() |
map() when you need a vector |
List where you expected numbers | map_dbl() etc. |
sapply() |
Return type varies with input | vapply() or map_*() |
| No argument validation | Cryptic errors deep in the call stack | cli_abort() at the top |
| Functions with side effects | Untestable, order-dependent | Return a value |
| Very long argument lists | Unusable | Pass a config list or a data frame |
Exercise 8.1 — A reusable summary function
Write summary_stats(data, var, by) that returns n, mean, SD, median, Q1, Q3, min and max of var, grouped by by. It must work with bare column names, and by must be optional.
Show solution
library(dplyr)
summary_stats <- function(data, var, by = NULL) {
data |>
summarise(
n = sum(!is.na({{ var }})),
nmiss = sum(is.na({{ var }})),
mean = mean({{ var }}, na.rm = TRUE),
sd = sd({{ var }}, na.rm = TRUE),
median = median({{ var }}, na.rm = TRUE),
q1 = quantile({{ var }}, 0.25, na.rm = TRUE),
q3 = quantile({{ var }}, 0.75, na.rm = TRUE),
min = min({{ var }}, na.rm = TRUE),
max = max({{ var }}, na.rm = TRUE),
.by = {{ by }}
)
}
summary_stats(adsl, AGE)
summary_stats(adsl, AGE, by = ARM)
summary_stats(adsl, AGE, by = c(ARM, SEX)).by = {{ by }} handles NULL naturally — summarise(.by = NULL) is ungrouped, which is exactly the desired default.
One refinement for real use: min() and max() of an all-NA vector return Inf/-Inf with a warning. Guard it:
min = if (all(is.na({{ var }}))) NA_real_ else min({{ var }}, na.rm = TRUE),Exercise 8.2 — Metadata-driven derivation
Given a specification tibble with columns source, target and factor, write apply_conversions(data, spec) that creates each target column as source * factor. Column names arrive as strings.
Show solution
library(dplyr); library(purrr); library(cli)
apply_conversions <- function(data, spec) {
missing_cols <- setdiff(spec$source, names(data))
if (length(missing_cols) > 0) {
cli_abort(c(
"Specification refers to column{?s} not present in {.arg data}.",
"x" = "Missing: {.var {missing_cols}}"
))
}
reduce(
seq_len(nrow(spec)),
function(dat, i) {
src <- spec$source[i]
tgt <- spec$target[i]
fct <- spec$factor[i]
mutate(dat, "{tgt}" := .data[[src]] * fct)
},
.init = data
)
}
spec <- tibble::tribble(
~source, ~target, ~factor,
"WEIGHT", "WEIGHT_LB", 2.20462,
"HEIGHT", "HEIGHT_IN", 0.393701
)
adsl |> apply_conversions(spec) |> select(USUBJID, WEIGHT, WEIGHT_LB)reduce() threads the data frame through one mutate() per specification row. The alternative — building a list of expressions and splicing with !!! — is more idiomatic tidyeval but harder to read:
apply_conversions2 <- function(data, spec) {
exprs <- set_names(
map2(spec$source, spec$factor, \(s, f) rlang::expr(.data[[!!s]] * !!f)),
spec$target
)
mutate(data, !!!exprs)
}Exercise 8.3 — Batch import with error collection
Write import_all(dir) that reads every CSV in a directory, adds a source column, combines them, and returns a list with data and failures (a tibble of file name and error message) rather than stopping on the first failure.
Show solution
library(purrr); library(dplyr); library(readr); library(fs)
import_all <- function(dir) {
files <- dir_ls(dir, glob = "*.csv")
if (length(files) == 0) {
cli::cli_warn("No CSV files found in {.path {dir}}")
return(list(data = tibble(), failures = tibble()))
}
results <- map(files, safely(\(f) read_csv(f, show_col_types = FALSE)))
ok <- map(results, "result")
err <- map(results, "error")
data <- ok |>
compact() |>
list_rbind(names_to = "source") |>
mutate(source = path_file(source))
failures <- tibble(
file = path_file(names(err)),
message = map_chr(err, \(e) if (is.null(e)) NA_character_ else conditionMessage(e))
) |>
filter(!is.na(message))
cli::cli_alert_info(
"Imported {length(compact(ok))}/{length(files)} file{?s}; {nrow(failures)} failure{?s}."
)
list(data = data, failures = failures)
}
res <- import_all("data/raw")
res$failures
#> # A tibble: 1 x 2
#> file message
#> <chr> <chr>
#> 1 lb_v2.csv Column `RESULT` can't be converted from character to doubleThe design choice worth noting: returning a list with both successes and failures, rather than warning and discarding. A batch job that silently skips three of forty files is worse than one that crashes — at least the crash is visible. This pattern gives you both the data and an auditable record of what did not load.
list_rbind() requires the tibbles to have compatible columns; if the files genuinely differ, list_rbind() fills with NA and you should check names() consistency explicitly before combining.
Recap
- The rule of three, but really: write a function when logic lives in two places
match.arg()for fixed choices;invisible()for side-effect functions{ }for bare column names,.data[[x]]/all_of()for stringsmap_dbl()and friends check the return type —sapply()does notpossibly()/safely()for batch jobs that must not stop at the first error- Validate arguments at the top with
cli_abort()and a helpful hint
Next: Error handling and debugging.