Functional Programming R Exercises: 18 Practice Problems
Eighteen runnable practice problems on functional programming in R, covering higher-order functions, the apply and map families, Reduce, closures, function factories, composition, partial application, and recursion. Solutions are hidden behind a reveal so you can solve before peeking.
Run the setup block once before attempting any exercise. Each problem is self-contained and assigns its result to ex_N_M, so you can check your answer side-by-side with the printed expected output.
Section 1. First-class and higher-order functions (3 problems)
Exercise 1.1: Type-check a mixed list with sapply
Task: A code reviewer is auditing type-safety in a script and needs the class of every element in a heterogeneous list. Use base R sapply() to apply class() across each element of list(a = c(1.5, 2.5), b = "hi", c = TRUE). The result should be a named character vector. Save it to ex_1_1.
Expected result:
#> a b c
#> "numeric" "character" "logical"
Difficulty: Beginner
Every element of the list has a type, and you want that type reported back for each one while keeping the list's names.
Pass class as a value (no parentheses) to sapply() along with the list.
Click to reveal solution
Explanation: sapply() treats a function as a value: you pass class (no parentheses) and it gets invoked on each list element. Because every result is a length-one character, sapply() simplifies the output to a named vector instead of returning a list. If you wanted to preserve list shape, lapply() is the safer choice and vapply(lst, class, character(1)) is the strictest.
Exercise 1.2: Return a function from a function
Task: A finance team analyst wants a reusable currency-formatter generator. Write make_formatter(prefix) that returns a single-argument function which pastes prefix and a number rounded to two decimals. Use the returned function to format 1234.5678 with prefix "$" and save the resulting string to ex_1_2.
Expected result:
#> [1] "$1234.57"
Difficulty: Intermediate
The outer function should hand back another function that already remembers the prefix it was given.
Inside make_formatter, return a function(x) that calls paste0() with prefix and format(round(x, 2), nsmall = 2).
Click to reveal solution
Explanation: Returning a function turns prefix into part of the inner function's enclosing environment. That captured value persists for every later call, which is the essence of a closure. The same pattern produces euro <- make_formatter("EUR ") for free, with no copy-paste. format(..., nsmall = 2) keeps trailing zeros (so 12.50 does not collapse to 12.5).
Exercise 1.3: Anonymous functions with the backslash shorthand
Task: Use the base R \(x) ... anonymous function shorthand together with sapply() to compute the cube of each integer from 1 to 6. The result should simplify to a numeric vector of length six. Save it to ex_1_3.
Expected result:
#> [1] 1 8 27 64 125 216
Difficulty: Beginner
You need a short throwaway one-argument operation applied across each integer in the range.
Combine sapply() over 1:6 with a \(x) lambda that computes x^3.
Click to reveal solution
Explanation: The \(x) lambda syntax (added in R 4.1) is identical to function(x) but two characters cheaper, which matters when you have many short callbacks. Use it for one-off helpers you would not reuse: anything you would name (like cube) deserves a top-level function() binding so it shows up in stack traces.
Section 2. The map family in base R and purrr (4 problems)
Exercise 2.1: One-line column audit with sapply over mtcars
Task: A reporting analyst wants a quick audit of every numeric column in mtcars. Use sapply() to compute the median of each column. Because all 11 columns are numeric and length-32, the result should simplify to a named numeric vector. Save it to ex_2_1.
Expected result:
#> mpg cyl disp hp drat wt qsec vs am gear carb
#> 19.200 6.000 196.300 123.000 3.695 3.325 17.710 0.000 0.000 4.000 2.000
Difficulty: Beginner
A data frame behaves like a list of columns, so applying one summary to each column yields a single number per column.
Call sapply() on mtcars with median.
Click to reveal solution
Explanation: A data frame is internally a list of columns, so sapply() iterates column-by-column. Because the per-column result is always a single numeric value, the output collapses to a named vector. For type-stable production code prefer vapply(mtcars, median, numeric(1)): if any column ever returns something unexpected, vapply() fails loudly instead of silently switching to a list.
Exercise 2.2: Column maxima with purrr map_dbl handling NAs
Task: A platform engineer is profiling a daily data export and needs the maximum value of each column in airquality, several of which contain NA. Use purrr::map_dbl() together with an anonymous function that passes na.rm = TRUE to max(). Save the resulting named numeric vector to ex_2_2.
Expected result:
#> Ozone Solar.R Wind Temp Month Day
#> 168.0 334.0 20.7 97.0 9.0 31.0
Difficulty: Intermediate
Several columns hold missing values, so the per-column maximum must be told to ignore them.
Use map_dbl() over airquality with a \(col) lambda that calls max(col, na.rm = TRUE).
Click to reveal solution
Explanation: map_dbl() is the type-stable variant of map(): it guarantees a double vector or errors at the first non-numeric result. The \(col) lambda is needed only because max() takes the extra na.rm argument; for the bare max case map_dbl(airquality, max) would also work but would return NA for any column containing missing values.
Exercise 2.3: Walk paired vectors with map2_dbl
Task: The growth team has paired before/after revenue figures for three campaigns and wants the percent change per campaign. Given before <- c(100, 120, 80) and after <- c(140, 110, 95), use map2_dbl() to compute (after - before) / before * 100. Save the numeric vector to ex_2_3.
Expected result:
#> [1] 40.00000 -8.33333 18.75000
Difficulty: Intermediate
The two vectors must advance together, one pair at a time, producing one percent change per pair.
Use map2_dbl() with a \(b, a) lambda computing (a - b) / b * 100.
Click to reveal solution
Explanation: map2_*() is the right call when two inputs must move in lockstep: it errors if the vectors differ in length, which would mask a bug if you used vectorised arithmetic alone. For three or more parallel inputs you would graduate to pmap_dbl() and pass a list. The plain vectorised form (after - before) / before * 100 works here too, but map2_dbl() makes the iteration explicit.
Exercise 2.4: Total price across many parallel vectors with pmap_dbl
Task: A pricing analyst has three parallel vectors describing line items: qty <- c(2, 5, 1, 4), unit_price <- c(9.99, 4.50, 19.95, 12.00), and tax_rate <- c(0.08, 0.08, 0.0, 0.10). Use pmap_dbl() to compute the per-line total qty * unit_price * (1 + tax_rate). Save the numeric vector to ex_2_4.
Expected result:
#> [1] 21.5784 24.3000 19.9500 52.8000
Difficulty: Intermediate
More than two vectors move in parallel, so each step needs one element drawn from every vector at once.
Pass list(qty, unit_price, tax_rate) to pmap_dbl() with a \(q, p, t) lambda.
Click to reveal solution
Explanation: pmap_*() takes a single list of equal-length vectors and binds the i-th element of each to the lambda's positional arguments. It scales to any number of inputs, unlike map2_*(). A common alternative is to bind columns into a tibble and call pmap_dbl(df, \(q, p, t) ...): the column names then become the parameter names, which reads cleanly when the vectors have meaningful labels.
Section 3. Reduce and accumulate (3 problems)
Exercise 3.1: Intersect three customer lists with Reduce
Task: An audit team has three customer-ID vectors pulled from three different systems and needs the IDs present in all three. Given lst <- list(c(1,2,3,4,5), c(2,3,5,7,8), c(2,3,5,9)), apply base R Reduce() with intersect to compute the common IDs. Save the resulting numeric vector to ex_3_1.
Expected result:
#> [1] 2 3 5
Difficulty: Intermediate
Fold the three vectors down pairwise to the elements that survive in every one of them.
Call Reduce() with intersect over lst.
Click to reveal solution
Explanation: Reduce() collapses a list by folding a binary function pairwise: it computes intersect(lst[[1]], lst[[2]]) first, then intersects the result with lst[[3]]. This generalises to N lists with zero loop code. The pattern works for any associative binary op: Reduce(union, ...), Reduce("+", ...), Reduce(merge, list_of_dfs), and so on.
Exercise 3.2: Compounded wealth multiplier with accumulate
Task: A finance team analyst needs the cumulative compounded return factor after each period for a series of period returns. Given r <- c(0.05, -0.02, 0.03, 0.01), use purrr::accumulate() with the binary function \(acc, x) acc * (1 + x) and .init = 1 so the result includes the starting wealth of 1. Save the numeric vector to ex_3_2.
Expected result:
#> [1] 1.000000 1.050000 1.029000 1.059870 1.070469
Difficulty: Advanced
You need every running compounded value along the way, not just the final one, and the series should start from a wealth of one.
Use accumulate() over r with the binary function \(acc, x) acc * (1 + x) and .init = 1.
Click to reveal solution
Explanation: Where reduce() keeps only the final value, accumulate() returns every intermediate state, which is exactly what cumulative wealth or running totals require. The .init = 1 argument seeds the fold with a starting wealth, so the output has length five for an input of four returns. A vectorised shortcut for this specific case is cumprod(1 + r), but the accumulate form generalises to any non-trivial state update rule.
Exercise 3.3: Right-associative subtraction with Reduce(right = TRUE)
Task: A code reviewer wants to demonstrate operator associativity by reducing the binary subtraction operator from right to left across the vector c(10, 4, 2, 1). Use base R Reduce() with right = TRUE so the computation becomes 10 - (4 - (2 - 1)). Save the integer result to ex_3_3.
Expected result:
#> [1] 7
#> # equivalent to: 10 - (4 - (2 - 1))
Difficulty: Advanced
The fold must begin from the rightmost pair so the bracketing nests inward from the right.
Call Reduce() with "-" over c(10, 4, 2, 1) and set right = TRUE.
Click to reveal solution
Explanation: Subtraction is not associative: left-to-right gives ((10 - 4) - 2) - 1 = 3, but right = TRUE evaluates 2 - 1 = 1 first, then 4 - 1 = 3, then 10 - 3 = 7. The same flag matters for any non-associative op (division, string concatenation order). It is also faster than building the expression manually with do.call() because Reduce avoids intermediate allocations.
Section 4. Filter, keep, and discard (2 problems)
Exercise 4.1: Keep only the numeric elements of a mixed list
Task: A data engineer is profiling a mixed list of column-summary objects and needs only the numeric ones for further math. Given lst <- list(1, "two", 3, "four", 5), use purrr::keep() with the predicate is.numeric to retain only the numeric elements. Save the filtered list (not a vector) to ex_4_1.
Expected result:
#> [[1]]
#> [1] 1
#>
#> [[2]]
#> [1] 3
#>
#> [[3]]
#> [1] 5
Difficulty: Beginner
Sift the mixed list and hold on only to the entries that are numbers, leaving it as a list.
Use keep() with the predicate is.numeric.
Click to reveal solution
Explanation: keep() and discard() are the inverse of each other and accept any predicate that returns a logical scalar per element. They preserve the input container shape: a list in, a list out, so you can still feed the result into another map(). The base R equivalent is Filter(is.numeric, lst), which behaves identically and ships with every R install.
Exercise 4.2: Discard short campaign names with a custom predicate
Task: A marketing analyst is cleaning campaign names and wants to drop any name with fewer than five characters. Given names <- c("spring", "ad", "summer-sale", "fy", "winter-promo"), use purrr::discard() with predicate \(x) nchar(x) < 5 to remove the short names. Save the surviving character vector to ex_4_2.
Expected result:
#> [1] "spring" "summer-sale" "winter-promo"
Difficulty: Intermediate
Throw away every name that is too short and keep the rest as a character vector.
Use discard() with a predicate \(x) nchar(x) < 5.
Click to reveal solution
Explanation: A predicate is just a function returning TRUE or FALSE per element. Here the lambda \(x) nchar(x) < 5 works on each string. The vectorised base alternative is names[nchar(names) >= 5], which is faster on large vectors but less composable if you later chain through a pipeline of keep() / discard() / map() steps with mixed predicates.
Section 5. Closures and function factories (3 problems)
Exercise 5.1: A counter that remembers its state across calls
Task: A junior analyst is learning R's environment model and wants a counter that remembers state between calls. Write make_counter() that returns a function which increments a private counter starting at zero and returns the new value. Call the returned counter three times and save the third returned value to ex_5_1.
Expected result:
#> [1] 3
#> # counter has been called three times
Difficulty: Intermediate
The returned function needs a private value that survives and updates between separate calls.
Initialize count <- 0, then return a function() that does count <<- count + 1 and returns count.
Click to reveal solution
Explanation: The <<- super-assignment writes to count in the enclosing function's environment instead of creating a new local binding. That captured environment is what makes the counter persist between calls. Each call to make_counter() produces an independent counter with its own private state, which is the closure idiom for object-without-class encapsulation.
Exercise 5.2: Power-of-N function factory
Task: A statistician wants a small family of monomial functions for hand-crafted polynomial features. Write power(n) that returns a function which raises its argument to the n-th power. Use it to build a cubing function called cube, apply it to the value 4, and save the numeric result to ex_5_2.
Expected result:
#> [1] 64
#> # cube(4) is 4 raised to the 3rd power
Difficulty: Intermediate
The outer function should build and return a tailored function that already knows which exponent to use.
Inside power, return function(x) x^n.
Click to reveal solution
Explanation: Function factories let you parameterise a family of functions with shared logic. square <- power(2), cube <- power(3), and quartic <- power(4) all share one definition. The risk is lazy evaluation: if n were itself a complex expression, it would not be evaluated until the inner function is called. Use force(n) inside the factory if you build many factories in a loop to lock in the value early.
Exercise 5.3: Memoized Fibonacci with a closure-private cache
Task: An ops engineer is benchmarking recursive computations and wants a memoized Fibonacci that caches previously seen values in a closure-private environment. Write memo_fib() that returns a function computing fib(n) and caching every result it computes. Use the returned function to compute fib(20) and save the integer result to ex_5_3.
Expected result:
#> [1] 6765
Difficulty: Advanced
Plain recursion repeats the same subproblems, so keep a private store of already-computed results and check it before recomputing.
Hold a cache vector in the closure, look it up first, recurse with Recall(), and write new results back with <<-.
Click to reveal solution
Explanation: The naive recursive Fibonacci recomputes the same subproblems exponentially many times. Storing results in a closure-private cache reduces it to linear work. Recall() refers to the currently executing function, which is robust to renaming the returned function later. The <<- writes the new value into the enclosing environment so the next call sees the updated cache.
Section 6. Composition, partial application, and recursion (3 problems)
Exercise 6.1: Compose round-and-stringify into one function
Task: A data engineer wants a one-step "round to two decimals, then stringify" formatter. Use purrr::compose() (which applies functions right-to-left by default) to combine \(x) round(x, 2) and as.character into a single function. Apply it to 3.14159 and save the resulting character string to ex_6_1.
Expected result:
#> [1] "3.14"
Difficulty: Intermediate
Chain two steps - round first, then turn into text - into one reusable function.
Use compose() with as.character and \(x) round(x, 2), remembering the rightmost runs first.
Click to reveal solution
Explanation: compose(f, g) returns a new function equivalent to function(x) f(g(x)): rightmost arg runs first. Composition is useful when you want to pass a single pre-built pipeline as a callback to map() or apply() without redefining the lambda each time. Pass .dir = "forward" if you prefer left-to-right reading order, which mirrors the magrittr pipe.
Exercise 6.2: Partial application baking in na.rm = TRUE
Task: A reporting analyst always wants to drop missing values when summarising columns and is tired of typing na.rm = TRUE everywhere. Use purrr::partial() to create mean_na from mean with na.rm = TRUE pre-baked. Apply mean_na to c(1, 2, NA, 4, 5) and save the numeric result to ex_6_2.
Expected result:
#> [1] 3
#> # NA dropped, mean of c(1, 2, 4, 5)
Difficulty: Intermediate
Pre-set one argument of an existing summary function so you never have to type it again.
Use partial() on mean with na.rm = TRUE.
Click to reveal solution
Explanation: partial() fixes a subset of arguments and returns a new function awaiting the rest. It is the canonical way to remove keyword-argument boilerplate from callbacks: map_dbl(df, mean_na) reads cleaner than map_dbl(df, \(x) mean(x, na.rm = TRUE)). Internally partial() constructs a function whose default args are the pre-supplied values, so call-site overrides still work.
Exercise 6.3: Recursive sum with an accumulator argument
Task: A hackathon participant wants to implement summation recursively to practice functional thinking and tail-call style. Write rec_sum(x, acc = 0) that recursively sums a numeric vector by peeling off the first element each call and adding it to the running accumulator. Apply it to 1:100 and save the integer result to ex_6_3.
Expected result:
#> [1] 5050
Difficulty: Advanced
Peel the first element off on each call, add it to a running total, and stop when nothing is left.
Add a base case if (length(x) == 0) return(acc), then recurse with rec_sum(x[-1], acc + x[1]).
Click to reveal solution
Explanation: The accumulator pattern turns recursion into a tail call (the recursive call is the final operation), which is the functional alternative to a for loop with a running total. R does not actually optimise tail calls, so this version will hit the default stack limit around 1:5000. For real work prefer the vectorised sum(x): the recursive form is here to illustrate the structure, not to replace base arithmetic.
What to do next
- Read the parent guide at Functional Programming in R for deeper theory on first-class functions, closures, and composition.
- For more apply-family drills move on to Apply Family Exercises in R.
- For data-wrangling practice that uses these patterns at scale try dplyr Exercises in R.
- If you want a workflow-oriented sequel, the purrr Exercises in R hub focuses specifically on the tidyverse map family.
r-statistics.co · Verifiable credential · Public URL
This document certifies mastery of
Functional Programming Mastery
Every certificate has a public verification URL that proves the holder passed the assessment. Anyone with the link can confirm the recipient and date.
54 learners have earned this certificate