Functional Programming

Marc Los Huertos

EA030 · Compiled 2026-04-14

Approach Comparison for Loops apply() Family Why purrr? map() Family Anonymous Functions map2() & pmap() walk() Real-World Examples List Columns

What this module covers. R gives you three distinct tools for repeating an operation: for loops, the base-R apply family, and purrr::map() and friends. This module shows all three, explains when each shines, and provides a side-by-side reference table so you can choose confidently. By the end you will be able to replace most loops with a single, readable pipeline — and know the handful of cases where a loop is still the right tool.


1. Three Approaches at a Glance

Before diving in, here is the full comparison. Return to this table whenever you need to pick the right tool.

Approach Origin Returns ✔ Pros ✘ Cons Best for…
for loop base R nothing (side effect) Universal; readable step-by-step logic; easy to debug with print(); handles early-exit (break) and skipping (next); no extra packages. Verbose boilerplate (pre-allocate, index, accumulate); output container must be managed manually; easy to accidentally grow a vector inside the loop (slow). Sequential steps where each iteration depends on the previous result; simulations with carry-forward state; teaching iteration concepts.
while / repeat base R nothing (side effect) Only option when the number of iterations is unknown in advance; handles convergence loops cleanly. Risk of infinite loops; even more boilerplate than for; rarely needed in data analysis. Iterative numerical algorithms (Newton–Raphson, EM); scraping until a condition is met.
apply() base R matrix or list No packages; fast on matrices; MARGIN= argument is elegant for rows vs columns. Only works on matrices/arrays; output type is inconsistent (sometimes simplifies, sometimes does not); confusing when function returns varying-length results. Row-wise or column-wise summaries on a numeric matrix.
lapply() base R always a list No packages; predictable list output; works on any list or vector; simple mental model. Always a list — must call unlist() or do.call(rbind, …) to flatten; no typed variants; anonymous function syntax is wordier. Quick one-off tasks in base R scripts; environments without tidyverse.
sapply() base R vector, matrix, or list Convenient when the result is a simple scalar per element — no unlist() needed. Silently returns a list when simplification fails — a major source of hard-to-find bugs; not type-safe; avoid in production code or functions. Interactive console exploration only. Prefer vapply() in scripts.
vapply() base R typed vector Type-safe version of sapply(); errors loudly if result does not match declared type; no packages. Verbose — must declare FUN.VALUE template; less readable than purrr typed variants. Base R scripts where you want the safety of purrr without the dependency.
mapply() base R vector or list Iterates over multiple arguments in parallel; no packages. Argument order is function-first, which feels backwards; same silent-simplification problem as sapply(); superseded by map2()/pmap(). Base R scripts needing parallel iteration; legacy code.
tapply() base R array Computes a summary statistic by group from a vector + factor in one call. Narrow use case; output is an array, not a data frame; superseded by dplyr::summarise(). Quick group summaries when you have a response vector and a grouping factor.
purrr::map() purrr list Consistent interface; typed variants (map_dbl, map_chr, …) enforce output type; terse ~ shorthand; composes naturally in pipes; map2()/pmap() for parallel inputs; excellent error messages. Requires tidyverse/purrr; ~/.x syntax has a short learning curve; slight overhead vs bare lapply() (negligible in practice). Almost all iteration in modern tidyverse workflows; list columns; multi-input mapping.

💡 Quick decision rule Does the next iteration depend on the previous result? → for loop. Are you in a pure base-R environment? → vapply() (safe) or lapply() (simple). Otherwise? → purrr::map() and friends.


2. for Loops — The Foundation

A for loop is the most transparent way to express iteration: you see exactly what happens at each step. Understanding it well also makes the motivation for map() concrete.

Basic syntax

sites <- list(
  kern   = c(12.1, 14.3, 11.8, 15.6, 13.2),
  mojave = c(8.4,   7.9,  9.1,  8.6),
  coast  = c(18.0, 19.4, 17.5, 20.1, 18.8, 19.0)
)

# Pre-allocate output — never grow a vector inside a loop
site_means <- vector("double", length(sites))
names(site_means) <- names(sites)

for (i in seq_along(sites)) {
  site_means[i] <- mean(sites[[i]])
}
site_means
##   kern mojave  coast 
##   13.4    8.5   18.8

The three-part boilerplate — pre-allocate, index, assign — is required every single time. That is the price for full visibility.

Iterating over names instead of indices

# Often cleaner to iterate over names directly
for (nm in names(sites)) {
  cat(nm, "→ n =", length(sites[[nm]]),
      "  mean =", round(mean(sites[[nm]]), 2), "\n")
}
## kern → n = 5   mean = 13.4 
## mojave → n = 4   mean = 8.5 
## coast → n = 6   mean = 18.8

next and break — flow control inside loops

# Skip sites with fewer than 5 observations; stop at first site with mean > 17
for (nm in names(sites)) {
  if (length(sites[[nm]]) < 5) { cat("Skipping", nm, "(too few obs)\n"); next }
  if (mean(sites[[nm]]) > 17)  { cat("Found high-mean site:", nm, "\n");  break }
  cat(nm, "passed filter, mean =", round(mean(sites[[nm]]), 2), "\n")
}
## kern passed filter, mean = 13.4 
## Skipping mojave (too few obs)
## Found high-mean site: coast

⚠️ The cardinal sin: growing a vector inside a loop results <- c(results, new_value) inside a loop copies the entire vector on every iteration — O(n²) memory and time. Always pre-allocate with vector(“double”, n) and fill by index.

When a for loop is genuinely the right choice

A loop is preferable to map() when each iteration depends on the result of the previous one — map() has no mechanism for carry-forward state:

# Simulate a random walk — each step depends on the last
set.seed(42)
n_steps  <- 200
position <- numeric(n_steps)
position[1] <- 0

for (i in 2:n_steps) {
  position[i] <- position[i - 1] + rnorm(1, mean = 0, sd = 1)
}

plot(position, type = "l", col = "#4a7c5e", lwd = 1.5,
     xlab = "Step", ylab = "Position",
     main = "Random walk — carry-forward state requires a loop",
     frame.plot = FALSE)
abline(h = 0, col = "#c8832a", lty = 2)

while and repeat

Use while when the stopping criterion is a condition, not a count:

# Newton–Raphson: find square root of 50 by iteration
x   <- 25          # initial guess
tol <- 1e-10
iter <- 0

while (abs(x^2 - 50) > tol) {
  x    <- x - (x^2 - 50) / (2 * x)   # Newton step
  iter <- iter + 1
}
cat(sprintf("sqrt(50) ≈ %.10f  (converged in %d iterations)\n", x, iter))
## sqrt(50) ≈ 7.0710678119  (converged in 6 iterations)
sqrt(50)           # confirm with built-in
## [1] 7.071068

3. The Base-R apply Family

Before purrr, R users relied on a family of apply functions. You will encounter them in legacy code and Stack Overflow answers, so it is worth understanding each one.

apply() — rows and columns of a matrix

# apply() is for matrices; MARGIN = 1 → rows, MARGIN = 2 → columns
m <- matrix(c(3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8), nrow = 4)
m
##      [,1] [,2] [,3]
## [1,]    3    5    5
## [2,]    1    9    3
## [3,]    4    2    5
## [4,]    1    6    8
apply(m, MARGIN = 1, FUN = mean)   # row means
## [1] 4.333333 4.333333 3.666667 5.000000
apply(m, MARGIN = 2, FUN = sum)    # column sums
## [1]  9 22 21

apply() does not work well on data frames with mixed types — use purrr::map_dbl() or dplyr::summarise() instead.

lapply() — list in, list out (always)

# lapply() always returns a list of the same length
lapply(sites, mean)
## $kern
## [1] 13.4
## 
## $mojave
## [1] 8.5
## 
## $coast
## [1] 18.8
# To get a plain vector you must unlist() or use sapply()
unlist(lapply(sites, mean))
##   kern mojave  coast 
##   13.4    8.5   18.8

sapply() — convenient but treacherous

# Works fine when every result is a scalar
sapply(sites, mean)
##   kern mojave  coast 
##   13.4    8.5   18.8
# ⚠ Silent failure: returns a list when simplification is impossible
# (e.g. results have different lengths — hard to catch downstream)
sapply(sites, range)   # 2-row matrix this time — not always obvious
##      kern mojave coast
## [1,] 11.8    7.9  17.5
## [2,] 15.6    9.1  20.1

⚠️ Never use sapply() inside a function you plan to reuse Its return type depends on the data it sees at runtime. Use vapply() (base R) or map_dbl()/map_chr() (purrr) to declare what you expect and get an error if the data disagrees.

vapply() — the safe alternative to sapply()

# Declare the expected return type via FUN.VALUE
vapply(sites, mean, FUN.VALUE = numeric(1))
##   kern mojave  coast 
##   13.4    8.5   18.8
# vapply() errors if the result does not match — safety at zero extra cost
# vapply(sites, range, FUN.VALUE = numeric(1))  # ← would error: length 2 ≠ 1

mapply() — multiple inputs in parallel

# mapply() is like map2() / pmap() but function-first, data-second
site_means_vec <- c(kern = 12, mojave = 8, coast = 18)
site_sds_vec   <- c(kern =  2, mojave = 1, coast =  3)

set.seed(42)
mapply(function(mu, s) round(mean(rnorm(30, mu, s)), 2),
       mu = site_means_vec,
       s  = site_sds_vec)
##   kern mojave  coast 
##  12.14   7.88  18.57

Compare to the purrr equivalent (function-last, which reads better in pipes):

set.seed(42)
map2_dbl(site_means_vec, site_sds_vec,
         ~ round(mean(rnorm(30, .x, .y)), 2))
##   kern mojave  coast 
##  12.14   7.88  18.57

tapply() — grouped summaries from a vector

# tapply(values, groups, FUN) — quick group summaries
ndvi_vals <- c(0.45, 0.48, 0.50, 0.62, 0.65, 0.70, 0.30, 0.28, 0.32)
watershed <- c("W1","W1","W1","W2","W2","W2","W3","W3","W3")

tapply(ndvi_vals, watershed, mean)
##        W1        W2        W3 
## 0.4766667 0.6566667 0.3000000
# Modern equivalent using dplyr:
data.frame(ndvi = ndvi_vals, ws = watershed) |>
  dplyr::summarise(mean_ndvi = mean(ndvi), .by = ws)
##   ws mean_ndvi
## 1 W1 0.4766667
## 2 W2 0.6566667
## 3 W3 0.3000000

Side-by-side: the same task five ways

# Task: compute the coefficient of variation (CV) for each site
cv <- function(x) sd(x) / mean(x) * 100

# 1. for loop
cv_loop <- vector("double", length(sites)); names(cv_loop) <- names(sites)
for (nm in names(sites)) cv_loop[nm] <- cv(sites[[nm]])

# 2. lapply + unlist
cv_lapply <- unlist(lapply(sites, cv))

# 3. sapply  (convenient, but not type-safe)
cv_sapply <- sapply(sites, cv)

# 4. vapply  (type-safe base R)
cv_vapply <- vapply(sites, cv, numeric(1))

# 5. purrr::map_dbl  (type-safe, tidyverse-friendly)
cv_purrr  <- map_dbl(sites, cv)

# All identical?
stopifnot(all.equal(cv_loop, cv_lapply),
          all.equal(cv_loop, cv_sapply),
          all.equal(cv_loop, cv_vapply),
          all.equal(cv_loop, cv_purrr))
cat("All five approaches produce identical results.\n")
## All five approaches produce identical results.

4. Why Replace Loops with map()?

A for loop in R is perfectly valid. But it forces you to manage three things at once: the index, the output container, and the logic. map() collapses all three into one expression focused on the logic alone.

Consider computing the mean of each numeric column in a data frame:

# ----- the for-loop way -----
df <- data.frame(a = rnorm(50, 10, 2),
                 b = rnorm(50, 20, 4),
                 c = rnorm(50,  5, 1))

results_loop <- vector("list", ncol(df))        # pre-allocate
for (i in seq_along(df)) {
  results_loop[[i]] <- mean(df[[i]])
}
results_loop
## [[1]]
## [1] 9.743503
## 
## [[2]]
## [1] 19.59302
## 
## [[3]]
## [1] 4.96217
# ----- the purrr way -----
map(df, mean)
## $a
## [1] 9.743503
## 
## $b
## [1] 19.59302
## 
## $c
## [1] 4.96217

The map() version removes the bookkeeping entirely. The intent — apply mean to every column — is immediately readable.

🔄 The core contract of map() map(.x, .f) takes every element of .x and feeds it to .f, collecting the results in a list. The input is a list or vector; the output is always a list of the same length.

The mental model is a conveyor belt: items flow in one end, the function processes each one, results flow out the other end — all in parallel, none skipped, none out of order.

Input (.x)
col_a
col_b
col_c
→
Function (.f)
mean()
→
Output (list)
10.1
19.8
5.04

5. The map() Family — Choosing Your Output Type

map() always returns a list. When you know what type the results should be, use a typed variant to get a flat vector instead of a list:

map()
→ list
Most flexible; elements can be any type or length.
map_dbl()
→ double vector
Use when .f returns a single number. Errors loudly if not.
map_int()
→ integer vector
Use for counts, indices, nrow() results.
map_chr()
→ character vector
Use when .f returns a string (e.g., names, labels).
map_lgl()
→ logical vector
Use for TRUE/FALSE checks across elements.
map_dfr()
→ data frame (row-bind)
Use when .f returns a data frame and you want rows stacked.
sites <- list(
  kern  = c(12.1, 14.3, 11.8, 15.6, 13.2),
  mojave = c(8.4,  7.9,  9.1,  8.6),
  coast = c(18.0, 19.4, 17.5, 20.1, 18.8, 19.0)
)

# map()     → list of numeric vectors (raw output)
map(sites, summary)
## $kern
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##    11.8    12.1    13.2    13.4    14.3    15.6 
## 
## $mojave
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##   7.900   8.275   8.500   8.500   8.725   9.100 
## 
## $coast
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##    17.5    18.2    18.9    18.8    19.3    20.1
# map_dbl() → named double vector — mean per site
map_dbl(sites, mean)
##   kern mojave  coast 
##   13.4    8.5   18.8
# map_int() → sample sizes
map_int(sites, length)
##   kern mojave  coast 
##      5      4      6
# map_lgl() → which sites have mean > 15?
map_lgl(sites, ~ mean(.x) > 15)
##   kern mojave  coast 
##  FALSE  FALSE   TRUE

💡 Type safety is a feature, not a restriction If you use map_dbl() and the function accidentally returns a character string, purrr errors immediately — at the source of the problem. A plain list would silently carry the wrong type for many lines before breaking somewhere unexpected.


6. Anonymous Functions — Writing .f Inline

You don’t have to write a named function to use map(). The tidyverse shorthand ~ expr creates an anonymous function where .x refers to the current element:

# Three equivalent ways to compute CV (sd/mean * 100) per site:

# 1. Named function (verbose but reusable)
coef_var <- function(x) sd(x) / mean(x) * 100
map_dbl(sites, coef_var)
##      kern    mojave     coast 
## 11.764092  5.843006  5.001132
# 2. purrr formula shorthand  ~  (most common in pipelines)
map_dbl(sites, ~ sd(.x) / mean(.x) * 100)
##      kern    mojave     coast 
## 11.764092  5.843006  5.001132
# 3. Base R anonymous function (R ≥ 4.1)
map_dbl(sites, \(x) sd(x) / mean(x) * 100)
##      kern    mojave     coast 
## 11.764092  5.843006  5.001132

The ~ shorthand is idiomatic purrr and reads well in pipes. The \(x) syntax is portable to any function that takes a function argument, not just purrr.

Passing extra arguments

When your function needs more than just the current element, pass extra arguments after .f:

# Trim 10% from each tail when computing mean
map_dbl(sites, mean, trim = 0.1)
##   kern mojave  coast 
##   13.4    8.5   18.8
# Or equivalently with the formula shorthand
map_dbl(sites, ~ mean(.x, trim = 0.1))
##   kern mojave  coast 
##   13.4    8.5   18.8

⚠️ Watch out: passing extra args to the wrong function Extra arguments go to .f, not to map() itself. If your anonymous function calls multiple functions internally, use the ~ form and be explicit about which gets which argument.


7. map2() and pmap() — Iterating Over Multiple Inputs

map() iterates over one thing at a time. map2() iterates over two things in parallel, and pmap() handles any number of inputs as a named list.

# Simulate data for multiple sites with different means AND sd's
site_means <- c(kern = 12, mojave = 8, coast = 18)
site_sds   <- c(kern =  2, mojave = 1, coast =  3)

# map2(): iterate over means and sds simultaneously
set.seed(42)
simulated <- map2(site_means, site_sds,
                  ~ rnorm(n = 30, mean = .x, sd = .y))
map_dbl(simulated, mean)   # check recovered means
##      kern    mojave     coast 
## 12.137174  7.878091 18.572308
# pmap(): same idea, but inputs come as a named list (or data frame)
params <- list(
  n    = c(20,  30,  40),
  mean = c(10,  50, 100),
  sd   = c( 2,   5,  10)
)
set.seed(7)
pmap(params, rnorm) |> map_dbl(mean)
## [1]  10.88649  50.51177 100.20658

pmap() is especially powerful when your parameters live in a data frame — each row becomes one call to the function:

model_specs <- tibble::tribble(
  ~species,      ~intercept, ~slope,
  "oak",              2.1,    0.84,
  "chaparral",        1.4,    1.12,
  "riparian",         3.5,    0.61
)

# predict biomass at a fixed precipitation value (450 mm)
precip <- 450
model_specs |>
  dplyr::mutate(
    biomass_kg = pmap_dbl(
      list(a = intercept, b = slope),
      ~ ..1 + ..2 * log(precip)
    )
  )
## # A tibble: 3 × 4
##   species   intercept slope biomass_kg
##   <chr>         <dbl> <dbl>      <dbl>
## 1 oak             2.1  0.84       7.23
## 2 chaparral       1.4  1.12       8.24
## 3 riparian        3.5  0.61       7.23

8. walk() — When You Only Care About Side Effects

map() always returns a value. Sometimes you want to do something — save a file, print a plot, write a message — without collecting any return value. That is walk().

# Create a small report for each site (printing here; in practice, write CSVs)
walk2(names(sites), sites, function(nm, vals) {
  cat(sprintf("Site: %-10s  n = %d  mean = %.2f  sd = %.2f\n",
              nm, length(vals), mean(vals), sd(vals)))
})
## Site: kern        n = 5  mean = 13.40  sd = 1.58
## Site: mojave      n = 4  mean = 8.50  sd = 0.50
## Site: coast       n = 6  mean = 18.80  sd = 0.94

The walk() family mirrors map() exactly — walk2(), pwalk() — but returns .x invisibly, making it pipeline-friendly:

# A pipeline that processes data AND saves outputs — walk() at the end
sites |>
  map(~ tibble::tibble(value = .x, z = scale(.x)[,1])) |>
  walk2(names(sites), ~ readr::write_csv(.x, paste0(.y, "_processed.csv")))

9. Real-World Examples

Fitting a model to each site

A common ecological workflow: split data by group, fit a model to each group, extract coefficients.

# Simulate a rainfall–NDVI dataset across 4 watersheds
set.seed(123)
n_sites <- 4
obs_df <- purrr::map_dfr(seq_len(n_sites), function(s) {
  n   <- 25
  ppt <- runif(n, 200, 600)
  tibble::tibble(
    site  = paste0("WS", s),
    ppt   = ppt,
    ndvi  = 0.3 + s * 0.05 + 0.0008 * ppt + rnorm(n, 0, 0.04)
  )
})

# Split → fit → extract slope, all in one pipeline
slopes <- obs_df |>
  split(~site) |>
  map(~ lm(ndvi ~ ppt, data = .x)) |>
  map_dfr(~ broom::tidy(.x)[2, c("term", "estimate", "p.value")],
          .id = "site")
slopes
## # A tibble: 4 × 4
##   site  term  estimate  p.value
##   <chr> <chr>    <dbl>    <dbl>
## 1 WS1   ppt   0.000834 7.59e-12
## 2 WS2   ppt   0.000660 1.30e- 9
## 3 WS3   ppt   0.000922 1.90e-12
## 4 WS4   ppt   0.000923 5.55e-10

Reading many files at once

# Suppose you have data/site_A.csv, data/site_B.csv, ...
csv_files <- list.files("data", pattern = "\\.csv$", full.names = TRUE)

all_data <- csv_files |>
  set_names(~ tools::file_path_sans_ext(basename(.x))) |>
  map_dfr(readr::read_csv, .id = "site")

The set_names() step labels each element with its filename before mapping, so .id = "site" in map_dfr() can populate a site column automatically. No loop, no rbind() accumulation pattern.

Applying the same plot to many groups

site_plots <- obs_df |>
  split(~site) |>
  imap(function(df, nm) {
    ggplot(df, aes(ppt, ndvi)) +
      geom_point(color = "#4a7c5e", alpha = .7, size = 2) +
      geom_smooth(method = "lm", se = FALSE, color = "#c8832a", linewidth = .8) +
      labs(title = nm, x = "Precipitation (mm)", y = "NDVI") +
      theme_minimal(base_size = 11) +
      theme(plot.title = element_text(family = "serif", face = "bold",
                                      color = "#1e3a2f"))
  })

# Print just the first two
patchwork::wrap_plots(site_plots[1:2], ncol = 2)

imap() is a shortcut for map2(.x, names(.x), .f) — it hands you both the element and its name, which is useful any time you need the label inside the function.


10. List Columns — purrr Meets the Tidyverse

map() returns lists. A data frame column can be a list. Combining these two facts gives you list columns — a powerful pattern for keeping model objects, plots, or data frames associated with their metadata in a single tidy tibble.

library(tidyr)

nested <- obs_df |>
  nest(data = -site) |>
  mutate(
    model   = map(data, ~ lm(ndvi ~ ppt, data = .x)),
    r2      = map_dbl(model, ~ summary(.x)$r.squared),
    coefs   = map(model, broom::tidy)
  )

nested |> select(site, r2)
## # A tibble: 4 × 2
##   site     r2
##   <chr> <dbl>
## 1 WS1   0.874
## 2 WS2   0.804
## 3 WS3   0.889
## 4 WS4   0.818
# Unnest to recover a flat table of coefficients
nested |>
  unnest(coefs) |>
  filter(term == "ppt") |>
  select(site, estimate, std.error, p.value)
## # A tibble: 4 × 4
##   site  estimate std.error  p.value
##   <chr>    <dbl>     <dbl>    <dbl>
## 1 WS1   0.000834 0.0000659 7.59e-12
## 2 WS2   0.000660 0.0000679 1.30e- 9
## 3 WS3   0.000922 0.0000680 1.90e-12
## 4 WS4   0.000923 0.0000907 5.55e-10

🧩 Why list columns matter Without list columns, per-group model objects have to live in separate named lists outside the data frame, divorced from their metadata. List columns keep everything together: the raw data, the model, its diagnostics, and its predictions all in one row per group. unnest() and map_*() let you extract exactly what you need downstream.


🔑 Iteration in R — key takeaways


What’s next?