Functions and tidy evaluation

Lesson 8 — R Programming

Lesson 8 of 12 Intermediate ~100 min

Learning objectives

  • Write functions with sensible arguments, defaults and return values
  • Explain lazy evaluation and where it surprises you
  • Use { }, ... and .data to write functions that take column names
  • Replace loops with purrr map 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] 1

Functions 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 found

Embracing 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.59

Read { 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_chg

Strings 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 double

That 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 formula

Prefer \(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)
}
Both are correct. Choose based on who has to maintain it.

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 double

The 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 strings
  • map_dbl() and friends check the return type — sapply() does not
  • possibly() / 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.

Back to top