Writing functions, control flow and the apply family

Define functions with lazy scoping in mind, replace loops with map functions, and choose between base apply, purrr and vectorisation.

Functions and control flow

normalise <- function(x, centre = TRUE, scale = TRUE) {
  stopifnot(is.numeric(x))
  if (centre) x <- x - mean(x, na.rm = TRUE)
  if (scale) {
    s <- sd(x, na.rm = TRUE)
    if (s == 0) return(x)          # early exit for a constant vector
    x <- x / s
  }
  x
}

# R passes arguments by promise: they are evaluated on first use, in the caller's scope
add <- function(a, b) a + b
add(1, stop("never evaluated"))     # returns 2 without evaluating b

for (i in seq_len(3)) print(i)

i <- 0
while (i < 3) { i <- i + 1; if (i == 2) next; cat(i, "\n") }
  • The last expression in a function body is its return value; return() is for early exits.
  • Arguments are evaluated lazily, which is why defaults can refer to other arguments, and why an unused argument can hold an error.
  • Use seq_len(n) rather than 1:n: when n is zero, 1:0 counts down instead of giving an empty sequence.

The apply family and purrr

xs <- list(a = 1:3, b = 4:6, c = numeric(0))

lapply(xs, mean)                          # always a list
sapply(xs, length)                        # simplifies when it can
vapply(xs, length, integer(1))            # declares the type and length it expects

mapply(function(a, b) a + b, 1:3, 4:6)    # element-wise over several vectors

library(purrr)
map(xs, mean)
map_dbl(xs, mean)                         # always a double vector
map_int(xs, ~ length(.x))
map2_dbl(1:3, 4:6, ~ .x + .y)
walk(xs, print)                           # for side effects, returns input invisibly
BasepurrrReturns
lapplymapA list, always
sapplymap_dbl, map_chrSimplified; the type can surprise you
vapplymap_int, map_lglA declared type, failing loudly on mismatch
mapplymap2Element-wise over two or more inputs
a loop with side effectswalkThe input, invisibly

sapply is the source of many subtle bugs: it returns a vector when all results are length one, a matrix when they share a length above one, and a list otherwise. That decision depends on your data, so the same code can return different types on different days.

Split, apply, combine

# base R
by_group <- split(mtcars, mtcars$cyl)
means <- lapply(by_group, function(d) mean(d$mpg))
do.call(rbind, means)

# purrr and dplyr, which is usually clearer
library(dplyr)
mtcars |> summarise(mean_mpg = mean(mpg), .by = cyl)

# when the operation returns a model rather than a number, nest first
models <- mtcars |>
  nest_by(cyl) |>
  mutate(fit = list(lm(mpg ~ wt, data = data)))

models$fit[[1]] |> summary()
💡
A loop is not automatically slower than *apply: both call an R function once per element. The real gain comes from vectorising the operation itself, replacing an element-wise function with a single vectorised call such as sum(), ifelse() or a join.

FAQ

When should I write a loop instead of a map?
When the iterations are genuinely sequential, when you want visible progress, or when the body has complex early-exit logic. Map functions are about expressing a transformation clearly, not about speed.
Why does my function see a variable from the global environment?
R uses lexical scoping, so a function that assigns with <<- or reads a name it did not define will reach into its enclosing environment. Pass values as arguments instead; a function that depends on globals is hard to test and easy to break.

Vectors, factors and data frames Debugging R: warnings, factors, NA and performance traps

Last refreshed 2026-09-18.