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 than1:n: whennis zero,1:0counts 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| Base | purrr | Returns |
|---|---|---|
lapply | map | A list, always |
sapply | map_dbl, map_chr | Simplified; the type can surprise you |
vapply | map_int, map_lgl | A declared type, failing loudly on mismatch |
mapply | map2 | Element-wise over two or more inputs |
| a loop with side effects | walk | The 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.Related
Vectors, factors and data frames Debugging R: warnings, factors, NA and performance traps
Last refreshed 2026-09-18.