03 · Apply Family Functions¶
R vectorizes most arithmetic automatically (x + 1 adds 1 to every
element), but plenty of real tasks need to call a function once per
element or once per group — parsing a list of strings, running a model per
category, applying a custom rule to every column. The apply family
(sapply, lapply, vapply, mapply, plus base apply for matrices) is
R's idiomatic way to do this without writing an explicit for loop. They
aren't faster than a well-written loop in every case, but they're more
concise, harder to get subtly wrong, and instantly recognizable to other R
programmers as "map this function over this thing."
Why not just use a for loop?¶
You can — R loops work fine. But an apply call reads as one clear statement of intent ("run this function on every element, collect the results") instead of manual setup:
nums <- c(4, 9, 16, 25)
# Explicit loop -- works, but verbose and needs a pre-allocated result vector
result <- numeric(length(nums))
for (i in seq_along(nums)) {
result[i] <- sqrt(nums[i])
}
result
# [1] 2 3 4 5
# Same thing with sapply -- one line, same result
sapply(nums, sqrt)
# [1] 2 3 4 5
seq_along(nums) (not 1:length(nums)) is the safe way to index a loop —
if nums were empty, 1:length(nums) becomes 1:0, which is c(1, 0),
not an empty sequence, silently looping over indices that don't exist.
seq_along() correctly produces an empty sequence for an empty input.
sapply() — simplify to a vector (or matrix) when possible¶
sapply() tries to simplify its result into the friendliest shape — a
named vector here. That's convenient, but it's also sapply()'s biggest
trap:
Trap: the simplified shape depends on the data, not just the code, so the same call can return a vector sometimes and a list or matrix other times, which breaks downstream code that assumed one particular shape:
sapply(1:3, function(x) x^2) # simplifies to a vector
# [1] 1 4 9
sapply(1:3, function(x) 1:x) # different length each time -- can't
# [[1]] # simplify to a vector, so you get a LIST
# [1] 1
# [[2]]
# [1] 1 2
# [[3]]
# [1] 1 2 3
If you need a guarantee about the output type, use vapply() instead.
lapply() — always returns a list¶
lapply() is sapply() without the simplification step — it always
returns a list, one element per input, no exceptions. That predictability
makes it the safer choice inside a function or pipeline where surprising
type changes would cause hard-to-trace bugs. Use unlist() afterward if
you're confident the result really is simplifiable:
vapply() — like sapply(), but with a guaranteed return type¶
FUN.VALUE declares the shape of each individual result (here, one
integer). If the function ever returns something that doesn't match — the
wrong length or type — vapply() throws an immediate, clear error instead
of silently producing a differently-shaped result three function calls
later:
vapply(1:3, function(x) 1:x, FUN.VALUE = integer(1))
# Error in vapply(1:3, function(x) 1:x, FUN.VALUE = integer(1)) :
# values must be length 1,
# but FUN(X[[2]]) result is length 2
This is a genuine advantage over sapply() for production code: fail loud
and immediately, at the exact call that produced a bad value, rather than
fail quietly and confusingly downstream.
mapply() — apply over multiple vectors in parallel¶
prices <- c(2.5, 4.0, 1.25)
quantities <- c(3, 2, 10)
mapply(function(p, q) p * q, prices, quantities)
# [1] 7.5 8.0 12.5
mapply() walks several vectors together, element by element — the
multi-argument counterpart to sapply(). Where sapply(x, f) calls
f(x[1]), f(x[2]), ..., mapply(f, x, y) calls f(x[1], y[1]),
f(x[2], y[2]), and so on.
apply() — row-wise or column-wise over a matrix or data frame¶
scores <- matrix(c(90, 85, 70, 95, 60, 100), nrow = 3, ncol = 2,
dimnames = list(c("Alice", "Bob", "Carol"), c("test1", "test2")))
scores
# test1 test2
# Alice 90 95
# Bob 85 60
# Carol 70 100
apply(scores, 1, mean) # MARGIN = 1: apply mean() to each ROW
# Alice Bob Carol
# 92.5 72.5 85.0
apply(scores, 2, mean) # MARGIN = 2: apply mean() to each COLUMN
# test1 test2
# 81.66667 85.00000
MARGIN = 1 means "collapse across columns, one result per row"; MARGIN =
2 means "collapse across rows, one result per column" — a distinction
that's easy to mix up. A useful mnemonic: rows are the first dimension in
dim()/matrix(), so MARGIN = 1 walks down the rows.
Anonymous functions and the \(x) shorthand¶
Every example above used a named function (sqrt, nchar) or a full
function(x) ... definition. R 4.1+ supports a shorthand for quick,
throwaway functions:
sapply(1:5, function(x) x^2) # traditional
sapply(1:5, \(x) x^2) # shorthand, R 4.1+ -- identical result
Both are common in real code; the shorthand is popular for short one-liners
passed directly into sapply/lapply/mutate, and the full function
keyword is often preferred for anything longer than one expression.
Apply family cheat sheet¶
| Function | Input | Output |
|---|---|---|
sapply(x, f) |
vector/list | simplified vector/matrix (or list if it can't simplify) |
lapply(x, f) |
vector/list | always a list |
vapply(x, f, FUN.VALUE) |
vector/list | vector, with a guaranteed, declared shape |
mapply(f, x, y) |
multiple vectors, walked together | simplified vector/matrix |
apply(m, MARGIN, f) |
matrix/data frame | vector/matrix, one result per row (MARGIN=1) or column (MARGIN=2) |
When to reach for dplyr instead¶
For anything operating on a data frame's columns — summarizing a group,
adding a derived column — dplyr's
mutate()/summarise() is usually more readable than apply()/sapply()
directly on a data frame, and doesn't have sapply()'s shape-guessing
problem. The apply family shines on plain vectors, lists, and matrices, or
inside your own functions where dplyr's data-frame-shaped verbs don't fit.
How It Actually Works¶
sapply(), lapply(), and friends look like loops from the outside, but
mechanically they're closer to vectorization-by-delegation: lapply(x, f)
is implemented in C (do_lapply), which iterates over x's elements at
the C level and, for each one, calls back into the R evaluator to run f.
That still costs one full R function-call dispatch per element — the same
cost a for loop pays — so lapply() isn't inherently faster than a
well-written for loop; its real advantages are that it guarantees a list
result with no manual pre-allocation, and it makes the "map this function
over this data" intent explicit and less bug-prone (no off-by-one index
errors, no accidentally-reused loop variable).
sapply() is literally lapply() followed by a simplification step:
it inspects the resulting list, and if every element is length-1 and the
same type, it flattens the list into an atomic vector using unlist()
internally — which is exactly why sapply()'s return type is unpredictable
(vector vs. list vs. matrix) and why vapply() exists: it takes an
explicit template for the expected output type/length and checks each
result against it immediately, failing fast rather than silently returning
whatever shape happened to come out.
Exercise¶
Given prices <- c(2.5, 4.0, 1.25, 9.99), write three versions of "round
every price to the nearest whole dollar": one with an explicit for loop,
one with sapply(), and one with vapply() declaring FUN.VALUE =
numeric(1). Then take words <- c("R", "python", "javascript") and use
vapply() to build a logical vector marking which words have more than 4
characters — and explain in a comment why vapply() is a safer choice than
sapply() here even though both would give the same answer.