23  Functional Programming

Class Objectives
  1. Understand the concept of mapping functions
  2. Learn the basic map() functions and their variants
  3. Learn how to use anonymous functions
  4. Work with multiple inputs using map2() and pmap()
Packages for this chapter

If you haven’t installed the following packages, run this code:

pak::pak("tidylog")

When you work with data in R, you often need to apply a function repeatedly—whether to every row in a tibble or every element in a vector. You might recall using rowwise() in past tutorials to apply functions to each row of nested data. In this session, we introduce map(), a function from the purrr package (part of the tidyverse) that makes these operations more concise and, in many cases, more powerful.

Think of map() as saying, “Take this function and apply it to each element in this list or vector.” While the idea might seem a little abstract at first, with some practice you’ll appreciate its flexibility and efficiency.

And, just for fun, purrr has the best logo among all tidyverse packages:

From https://purrr.tidyverse.org/

Let’s dive in by loading our “best buddy” packages:

library(tidyverse)
library(tidylog)

23.1 Basics of Mapping

23.1.1 Example: Reverse several words

Imagine you have a few words and you want to reverse the letters of each word. This is a good example because our helper function only works on one word at a time (it is not vectorized): strsplit(w, "")[[1]] takes the first (and only) word.

reverse_word <- function(w) {
    paste(rev(strsplit(w, "")[[1]]), collapse = "")
}

reverse_word("mango")
[1] "ognam"

Calling reverse_word() on the whole column directly would not work, because it would only look at the first word. Previously, you might have used rowwise() as follows:

tibble(words = c("mango", "lychee", "blueberry")) |>
    rowwise() |>
    mutate(reversed = reverse_word(words)) |>
    ungroup()

Here, we had to explicitly mark the tibble as rowwise and then ungroup the results. This works fine, but it can be a bit verbose.

We can use map() instead:

tibble(words = c("mango", "lychee", "blueberry")) |>
    mutate(reversed = map(words, reverse_word))

What’s happening here?

  • map(words, reverse_word) tells R: “For every element in the words vector, apply reverse_word().”
  • The result is a list column because, by default, map() returns a list.

23.1.2 How map() Works

The basic syntax is very simple:

map(.x, .f)
  • .x: The list or vector you want to iterate over.
  • .f: The function to apply to each element.

map() takes each element from .x and sends it through the function .f, collecting the results in a new list. It is not that scary, right?

23.1.3 Getting a Vector Instead of a List

Since map() always returns a list, you might sometimes want to convert the results into a vector format. There are two common ways to do this:

After applying map(), you can “unnest” the list column to turn it into a regular vector:

tibble(words = c("mango", "lychee", "blueberry")) |>
    mutate(reversed = map(words, reverse_word)) |>
    unnest(reversed)

The function map_chr() directly returns a character vector, so you don’t have to unnest later:

tibble(words = c("mango", "lychee", "blueberry")) |>
    mutate(reversed = map_chr(words, reverse_word))

For atomic outputs, use the type-specific variants: map_chr() (character), map_dbl() (numeric), map_int() (integer) and map_lgl() (logical). They are stricter than plain map(): they throw an error if a result is not a single value of the expected type, which catches bugs early. There is also map_vec() (introduced in purrr 1.0.0, end of 2022, see here), which guesses the output type and is mainly useful for S3 vectors such as dates and factors.

23.2 Anonymous Functions with map()

Because map() takes the function as an argument, you can also define the function right there, without formally naming it. This is called an anonymous function (a.k.a. lambda function): a function you define “on the fly”, which makes your code more concise when you only need it once.

23.2.1 Using rowwise()

Let’s say you want to generate a series of random samples from a uniform distribution. With rowwise() you can write the code inline:

tibble(n = c(10, 20, 30)) |>
    rowwise() |>
    mutate(sample = list(runif(n, min = -1, max = 1))) |>
    ungroup()
1
Generate n random samples from a uniform distribution from -1 to 1. We wrap the result in list() because each row stores a whole vector.

23.2.2 Using Anonymous Functions

With map(), the same task looks like this (an anonymous function defined right in the call):

tibble(
    num_samples = c(10, 20, 30)
) |>
    mutate(
        sample = map(num_samples, \(x) runif(x, min = -1, max = 1))
    )

Explanation:

  • \(x) defines an anonymous function with x as its argument.
  • The body of the function (runif(x, min = -1, max = 1)) uses x directly.
  • This means “for each value in num_samples, generate x random numbers using runif().”

Older code might use a formula-style notation for anonymous functions. Although less common now, it’s still seen in many codebases, so it’s good to know about it. The syntax is as follows:

tibble(
    num_samples = c(10, 20, 30)
) |>
    mutate(
        sample = map(num_samples, ~ runif(., min = -1, max = 1))
    )

In this version, ~ denotes an anonymous function and . is a placeholder for the argument.

23.3 Mapping with Multiple Inputs

Sometimes, you need to work with more than one column simultaneously. Purrr provides two handy functions: map2() and pmap().

23.3.1 map2(): Working with Two Inputs

map2() applies a function to two vectors element-wise. For example:

tibble(
    num_samples = c(10, 20, 30),
    sample_min = c(-1, -2, -3)
) |>
    mutate(
        sample = map2(
            num_samples, sample_min,
            \(n, lo) runif(n, min = lo, max = 1)
        )
    )

How It Works:

  • map2() takes the first element from num_samples and the first element from sample_min
  • We pass them as n and lo to the anonymous function \(n, lo) runif(n, min = lo, max = 1).
  • This is repeated for all corresponding pairs.

23.3.2 pmap(): Working with Multiple Inputs

If you have more than two vectors, use pmap():

tibble(
    num_samples = c(10, 20, 30),
    sample_min = c(-1, -2, -3),
    sample_max = c(2, 3, 4)
) |>
    mutate(
        sample = pmap(
            list(num_samples, sample_min, sample_max),
            \(n, lo, hi) runif(n, min = lo, max = hi)
        )
    )
1
The pmap() function takes a list of columns. So you need to wrap your columns in a list.
2
The anonymous function takes three arguments as what you input in pmap().

23.4 When to use map() vs rowwise()

Both map() and rowwise() help you loop over your data, but they have different vibes:

  • map() is your go-to for functional programming. It’s built to apply a function to every element in a vector or list and is extra handy when you’re juggling multiple inputs with helpers like map2() or pmap(). If you dig writing concise code with anonymous functions, map() is where it’s at.

  • rowwise() is more about thinking in rows. It’s great if you want to work row by row in a tibble, and it feels pretty intuitive—like taking it one row at a time. Just keep in mind that once you’re done, you’ll usually want to call ungroup() to tidy up your tibble.

In my opinion, map() usually leads to cleaner and more streamlined code, especially if you’re comfortable with a functional programming approach. That said, rowwise() is documented in dplyr’s own vignettes, and I personally find it much easier to write and read. You do not need to use map() if you do not want to. If you’re just dipping your toes into these concepts, rowwise() is a natural stepping stone to functional programming.