Functions

Introduction

One of the best ways to improve your reach as a data scientist is to write functions. Functions allow you to automate common tasks in a more powerful and general way than copy-and-pasting. Writing a function has three big advantages over using copy-and-paste:

  1. You can give a function an evocative name that makes your code easier to understand.

  2. As requirements change, you only need to update code in one place, instead of many.

  3. You eliminate the chance of making incidental mistakes when you copy and paste (i.e. updating a variable name in one place, but not in another).

A good rule of thumb is to consider writing a function whenever you’ve copied and pasted a block of code more than twice (i.e. you now have three copies of the same code). In this chapter, you’ll learn about three useful types of functions:

Each of these sections include many examples to help you generalize the patterns that you see. These examples wouldn’t be possible without the help of folks of twitter, and we encourage follow the links in the comment to see original inspirations. You might also want to read the original motivating tweets for general functions and plotting functions to see even more functions.

Prerequisites

We’ll wrap up a variety of functions from around the tidyverse. We’ll also use nycflights13 as a source of familiar data to use our functions with.

library(tidyverse)
library(nycflights13)

Vector functions

We’ll begin with vector functions: functions that take one or more vectors and return a vector result. For example, take a look at this code. What does it do?

df <- tibble(
  a = rnorm(5),
  b = rnorm(5),
  c = rnorm(5),
  d = rnorm(5),
)

df |> mutate(
  a = (a - min(a, na.rm = TRUE)) / 
    (max(a, na.rm = TRUE) - min(a, na.rm = TRUE)),
  b = (b - min(b, na.rm = TRUE)) / 
    (max(b, na.rm = TRUE) - min(a, na.rm = TRUE)),
  c = (c - min(c, na.rm = TRUE)) / 
    (max(c, na.rm = TRUE) - min(c, na.rm = TRUE)),
  d = (d - min(d, na.rm = TRUE)) / 
    (max(d, na.rm = TRUE) - min(d, na.rm = TRUE)),
)
#> # A tibble: 5 × 4
#>       a     b     c     d
#>   <dbl> <dbl> <dbl> <dbl>
#> 1 0.339  2.59 0.291 0    
#> 2 0.880  0    0.611 0.557
#> 3 0      1.37 1     0.752
#> 4 0.795  1.37 0     1    
#> 5 1      1.34 0.580 0.394

You might be able to puzzle out that this rescales each column to have a range from 0 to 1. But did you spot the mistake? When Hadley wrote this code he made an error when copying-and-pasting and forgot to change an a to a b. Preventing this type of mistake of is one very good reason to learn how to write functions.

Writing a function

To write a function you need to first analyse your repeated code to figure what parts are constant and what parts vary. If we take the code above and pull it outside of mutate() it’s a little easier to see the pattern because each repetition is now one line:

(a - min(a, na.rm = TRUE)) / (max(a, na.rm = TRUE) - min(a, na.rm = TRUE))
(b - min(b, na.rm = TRUE)) / (max(b, na.rm = TRUE) - min(b, na.rm = TRUE))
(c - min(c, na.rm = TRUE)) / (max(c, na.rm = TRUE) - min(c, na.rm = TRUE))
(d - min(d, na.rm = TRUE)) / (max(d, na.rm = TRUE) - min(d, na.rm = TRUE))  

To make this a bit clearer we can replace the bit that varies with :

(█ - min(█, na.rm = TRUE)) / (max(█, na.rm = TRUE) - min(█, na.rm = TRUE))

To turn this into a function you need three things:

  1. A name. Here we’ll use rescale01 because this function rescales a vector to lie between 0 and 1.

  2. The arguments. The arguments are things that vary across calls and our analysis above tells us that have just one. We’ll call it x because this is the conventional name for a numeric vector.

  3. The body. The body is the code that repeated across all the calls.

Then you create a function by following the template:

name <- function(arguments) {
  body
}

For this case that leads to:

rescale01 <- function(x) {
  (x - min(x, na.rm = TRUE)) / (max(x, na.rm = TRUE) - min(x, na.rm = TRUE))
}

At this point you might test with a few simple inputs to make sure you’ve captured the logic correctly:

rescale01(c(-10, 0, 10))
#> [1] 0.0 0.5 1.0
rescale01(c(1, 2, 3, NA, 5))
#> [1] 0.00 0.25 0.50   NA 1.00

Then you can rewrite the call to mutate() as:

df |> mutate(
  a = rescale01(a),
  b = rescale01(b),
  c = rescale01(c),
  d = rescale01(d),
)
#> # A tibble: 5 × 4
#>       a     b     c     d
#>   <dbl> <dbl> <dbl> <dbl>
#> 1 0.339 1     0.291 0    
#> 2 0.880 0     0.611 0.557
#> 3 0     0.530 1     0.752
#> 4 0.795 0.531 0     1    
#> 5 1     0.518 0.580 0.394

(In #chp-iteration, you’ll learn how to use across() to reduce the duplication even further so all you need is df |> mutate(across(a:d, rescale01))).

Improving our function

You might notice rescale01() function does some unnecessary work — instead of computing min() twice and max() once we could instead compute both the minimum and maximum in one step with range():

rescale01 <- function(x) {
  rng <- range(x, na.rm = TRUE)
  (x - rng[1]) / (rng[2] - rng[1])
}

Or you might try this function on a vector that includes an infinite value:

x <- c(1:10, Inf)
rescale01(x)
#>  [1]   0   0   0   0   0   0   0   0   0   0 NaN

That result is not particularly useful so we could ask range() to ignore infinite values:

rescale01 <- function(x) {
  rng <- range(x, na.rm = TRUE, finite = TRUE)
  (x - rng[1]) / (rng[2] - rng[1])
}
rescale01(x)
#>  [1] 0.0000000 0.1111111 0.2222222 0.3333333 0.4444444 0.5555556 0.6666667
#>  [8] 0.7777778 0.8888889 1.0000000       Inf

These changes illustrate an important benefit of functions: because we’ve moved the repeated code into a function, we only need to make the change in one place.

Mutate functions

Now you’ve got the basic idea of functions, lets take a look a whole bunch of examples. We’ll start by looking at “mutate” functions, functions that work well like mutate() and filter() because they return an output the same length as the input.

Lets start with a simple variation of rescale01(). Maybe you want compute the Z-score, rescaling a vector to have to a mean of zero and a standard deviation of one:

z_score <- function(x) {
  (x - mean(x, na.rm = TRUE)) / sd(x, na.rm = TRUE)
}

Or maybe you want to wrap up a straightforward case_when() in order to give it a useful name. For example, this clamp() function ensures all values of a vector lie in between a minimum or a maximum:

clamp <- function(x, min, max) {
  case_when(
    x < min ~ min,
    x > max ~ max,
    .default = x
  )
}
clamp(1:10, min = 3, max = 7)
#>  [1] 3 3 3 4 5 6 7 7 7 7

Or maybe you’d rather mark those values as NAs:

na_outside <- function(x, min, max) {
  case_when(
    x < min ~ NA,
    x > max ~ NA,
    .default = x
  )
}
na_outside(1:10, min = 3, max = 7)
#>  [1] NA NA  3  4  5  6  7 NA NA NA

Of course functions don’t just need to work with numeric variables. You might want to extract out some repeated string manipulation. Maybe you need to make the first character upper case:

first_upper <- function(x) {
  str_sub(x, 1, 1) <- str_to_upper(str_sub(x, 1, 1))
  x
}
first_upper("hello")
#> [1] "Hello"

Or maybe you want to strip percent signs, commas, and dollar signs from a string before converting it into a number:

# https://twitter.com/NVlabormarket/status/1571939851922198530
clean_number <- function(x) {
  is_pct <- str_detect(x, "%")
  num <- x |> 
    str_remove_all("%") |> 
    str_remove_all(",") |> 
    str_remove_all(fixed("$")) |> 
    as.numeric(x)
  if_else(is_pct, num / 100, num)
}
clean_number("$12,300")
#> [1] 12300
clean_number("45%")
#> [1] 0.45

Sometimes your functions will be highly specialized for one data analysis. For example, if you have a bunch of variables that record missing values as 997, 998, or 999, you might want to write a function to replace them with NA:

fix_na <- function(x) {
  if_else(x %in% c(997, 998, 999), NA, x)
}

We’ve focused on examples that take a single vector because we think they’re the most common. But there’s no reason that your function can’t take multiple vector inputs. For example, you might want to compute the distance between two locations on the globe using the haversine formula. This requires four vectors:

# https://twitter.com/RosanaFerrero/status/1574722120428539906/photo/1
haversine <- function(long1, lat1, long2, lat2, round = 3) {
  # convert to radians
  long1 <- long1 * pi / 180
  lat1  <- lat1  * pi / 180
  long2 <- long2 * pi / 180
  lat2  <- lat2  * pi / 180
  
  R <- 6371 # Earth mean radius in km
  a <- sin((lat2 - lat1) / 2)^2 + 
    cos(lat1) * cos(lat2) * sin((long2 - long1) / 2)^2
  d <- R * 2 * asin(sqrt(a))
  
  round(d, round)
}

Summary functions

Another important family of vector functions is summary functions, functions that return a single value for use in summarize(). Sometimes this can just be a matter of setting a default argument or two:

commas <- function(x) {
  str_flatten(x, collapse = ", ", last = " and ")
}
commas(c("cat", "dog", "pigeon"))
#> [1] "cat, dog and pigeon"

Or you might wrap up a simple computation, like for the coefficient of variation, which divides standard deviation by the mean:

cv <- function(x, na.rm = FALSE) {
  sd(x, na.rm = na.rm) / mean(x, na.rm = na.rm)
}
cv(runif(100, min = 0, max = 50))
#> [1] 0.5196276
cv(runif(100, min = 0, max = 500))
#> [1] 0.5652554

Or maybe you just want to make a common pattern easier to remember by giving it a memorable name:

# https://twitter.com/gbganalyst/status/1571619641390252033
n_missing <- function(x) {
  sum(is.na(x))
} 

You can also write functions with multiple vector inputs. For example, maybe you want to compute the mean absolute prediction error to help you compare model predictions with actual values:

# https://twitter.com/neilgcurrie/status/1571607727255834625
mape <- function(actual, predicted) {
  sum(abs((actual - predicted) / actual)) / length(actual)
}

RStudio

Once you start writing functions, there are two RStudio shortcuts that are super useful:

  • To find the definition of a function that you’ve written, place the cursor on the name of the function and press F2.

  • To quickly jump to a function, press Ctrl + . to open the fuzzy file and function finder and type the first few letters of your function name. You can also navigate to files, Quarto sections, and more, making it a very handy navigation tool.

Exercises

  1. Practice turning the following code snippets into functions. Think about what each function does. What would you call it? How many arguments does it need?

    mean(is.na(x))
    mean(is.na(y))
    mean(is.na(z))
    
    x / sum(x, na.rm = TRUE)
    y / sum(y, na.rm = TRUE)
    z / sum(z, na.rm = TRUE)
    
    round(x / sum(x, na.rm = TRUE) * 100, 1)
    round(y / sum(y, na.rm = TRUE) * 100, 1)
    round(z / sum(z, na.rm = TRUE) * 100, 1)
  2. In the second variant of rescale01(), infinite values are left unchanged. Can you rewrite rescale01() so that -Inf is mapped to 0, and Inf is mapped to 1?

  3. Given a vector of birthdates, write a function to compute the age in years.

  4. Write your own functions to compute the variance and skewness of a numeric vector. Variance is defined as \[ \mathrm{Var}(x) = \frac{1}{n - 1} \sum_{i=1}^n (x_i - \bar{x}) ^2 \text{,} \] where \(\bar{x} = (\sum_i^n x_i) / n\) is the sample mean. Skewness is defined as \[ \mathrm{Skew}(x) = \frac{\frac{1}{n-2}\left(\sum_{i=1}^n(x_i - \bar x)^3\right)}{\mathrm{Var}(x)^{3/2}} \text{.} \]

  5. Write both_na(), a summary function that takes two vectors of the same length and returns the number of positions that have an NA in both vectors.

  6. Read the documentation to figure out what the following functions do. Why are they useful even though they are so short?

    is_directory <- function(x) file.info(x)$isdir
    is_readable <- function(x) file.access(x, 4) == 0

Data frame functions

Vector functions are useful for pulling out code that’s repeated within a dplyr verb. But you’ll often also repeat the verbs themselves, particularly within a large pipeline. When you notice yourself copying and pasting multiple verbs multiple times, you might think about writing a data frame function. Data frame functions work like dplyr verbs: they take a data frame as the first argument, some extra arguments that say what to do with it, and return a data frame or vector.

To let you write a function that uses dplyr verbs, we’ll first introduce you to the challenge of indirection and how you can overcome it with embracing, {{ }}. With this theory under your belt, we’ll then show you a bunch of examples to illustrate what you might do with it.

Indirection and tidy evaluation

When you start writing functions that use dplyr verbs you rapidly hit the problem of indirection. Let’s illustrate the problem with a very simple function: pull_unique(). The goal of this function is to pull() the unique (distinct) values of a variable:

pull_unique <- function(df, var) {
  df |> 
    distinct(var) |> 
    pull(var)
}

If we try and use it, we get an error:

diamonds |> pull_unique(clarity)
#> Error in `distinct()` at ]8;line = 38:col = 2;file:///Users/hadleywickham/Documents/dplyr/dplyr/R/pull.Rdplyr/R/pull.R:38:2]8;;:
#> ! Must use existing variables.
#> ✖ `var` not found in `.data`.

To make the problem a bit more clear we can use a made up data frame:

df <- tibble(var = "var", x = "x", y = "y")
df |> pull_unique(x)
#> [1] "var"
df |> pull_unique(y)
#> [1] "var"

Regardless of how we call pull_unique() it always does df |> distinct(var) |> pull(var), instead of df |> distinct(x) |> pull(x) or df |> distinct(y) |> pull(y). This is a problem of indirection, and it arises because dplyr uses tidy evaluation to allow you to refer to the names of variables inside your data frame without any special treatment.

Tidy evaluation is great 95% of the time because it makes your data analyses very concise as you never have to say which data frame a variable comes from; it’s obvious from the context. The downside of tidy evaluation comes when we want to wrap up repeated tidyverse code into a function. Here we need some way to tell distinct() and pull() not to treat var as the name of a variable, but instead look inside var for the variable we actually want to use.

Tidy evaluation includes a solution to this problem called embracing 🤗. Embracing a variable means to wrap it in braces so (e.g.) var becomes {{ var }}. Embracing a variable tells dplyr to use the value stored inside the argument, not the argument as the literal variable name. One way to remember what’s happening is to think of {{ }} as looking down a tunnel — {{ var }} will make a dplyr function look inside of var rather than looking for a variable called var.

So to make pull_unique() work we need to replace var with {{ var }}:

pull_unique <- function(df, var) {
  df |> 
    distinct({{ var }}) |> 
    pull({{ var }})
}
diamonds |> pull_unique(clarity)
#> [1] SI2  SI1  VS1  VS2  VVS2 VVS1 I1   IF  
#> Levels: I1 < SI2 < SI1 < VS2 < VS1 < VVS2 < VVS1 < IF

Success!

When to embrace?

So the key challenge in writing data frame functions is figuring out which arguments need to be embraced. Fortunately this is easy because you can look it up from the documentation 😄. There are two terms to look for in the docs which corresponding to the two most common sub-types of tidy evaluation:

Your intuition about which arguments use tidy evaluation should be good for many common functions — just think about whether you can compute (e.g. x + 1) or select (e.g. a:x).

In the following sections we’ll explore the sorts of handy functions you might write once you understand embracing.

Common use cases

If you commonly perform the same set of summaries when doing initial data exploration, you might consider wrapping them up in a helper function:

summary6 <- function(data, var) {
  data |> summarise(
    min = min({{ var }}, na.rm = TRUE),
    mean = mean({{ var }}, na.rm = TRUE),
    median = median({{ var }}, na.rm = TRUE),
    max = max({{ var }}, na.rm = TRUE),
    n = n(),
    n_miss = sum(is.na({{ var }})),
    .groups = "drop"
  )
}
diamonds |> summary6(carat)
#> # A tibble: 1 × 6
#>     min  mean median   max     n n_miss
#>   <dbl> <dbl>  <dbl> <dbl> <int>  <int>
#> 1   0.2 0.798    0.7  5.01 53940      0

(Whenever you wrap summarise() in a helper, we think it’s good practice to set .groups = "drop" to both avoid the message and leave the data in an ungrouped state.)

The nice thing about this function is because it wraps summarise() you can used it on grouped data:

diamonds |> 
  group_by(cut) |> 
  summary6(carat)
#> # A tibble: 5 × 7
#>   cut         min  mean median   max     n n_miss
#>   <ord>     <dbl> <dbl>  <dbl> <dbl> <int>  <int>
#> 1 Fair       0.22 1.05    1     5.01  1610      0
#> 2 Good       0.23 0.849   0.82  3.01  4906      0
#> 3 Very Good  0.2  0.806   0.71  4    12082      0
#> 4 Premium    0.2  0.892   0.86  4.01 13791      0
#> 5 Ideal      0.2  0.703   0.54  3.5  21551      0

Because the arguments to summarize are data-masking that also means that the var argument to summary6() is data-masking. That means you can also summarize computed variables:

diamonds |> 
  group_by(cut) |> 
  summary6(log10(carat))
#> # A tibble: 5 × 7
#>   cut          min    mean  median   max     n n_miss
#>   <ord>      <dbl>   <dbl>   <dbl> <dbl> <int>  <int>
#> 1 Fair      -0.658 -0.0273  0      0.700  1610      0
#> 2 Good      -0.638 -0.133  -0.0862 0.479  4906      0
#> 3 Very Good -0.699 -0.164  -0.149  0.602 12082      0
#> 4 Premium   -0.699 -0.125  -0.0655 0.603 13791      0
#> 5 Ideal     -0.699 -0.225  -0.268  0.544 21551      0

To summarize multiple variables you’ll need to wait until #sec-across, where you’ll learn how to use across().

Another popular summarise() helper function is a version of count() that also computes proportions:

# https://twitter.com/Diabb6/status/1571635146658402309
count_prop <- function(df, var, sort = FALSE) {
  df |>
    count({{ var }}, sort = sort) |>
    mutate(prop = n / sum(n))
}
diamonds |> count_prop(clarity)
#> # A tibble: 8 × 3
#>   clarity     n   prop
#>   <ord>   <int>  <dbl>
#> 1 I1        741 0.0137
#> 2 SI2      9194 0.170 
#> 3 SI1     13065 0.242 
#> 4 VS2     12258 0.227 
#> 5 VS1      8171 0.151 
#> 6 VVS2     5066 0.0939
#> # … with 2 more rows

This function has three arguments: df, var, and sort, and only var needs to be embraced because it’s passed to count() which uses data-masking for all variables in .

Or maybe you want to find the sorted unique values of a variable for a subset of the data. Rather than supplying a variable and a value to do the filtering, we’ll allow the user to supply a condition:

unique_where <- function(df, condition, var) {
  df |> 
    filter({{ condition }}) |> 
    distinct({{ var }}) |> 
    arrange({{ var }}) |> 
    pull({{ var }})
}

# Find all the destinations in December
flights |> unique_where(month == 12, dest)
#>  [1] "ABQ" "ALB" "ATL" "AUS" "AVL" "BDL" "BGR" "BHM" "BNA" "BOS" "BQN" "BTV"
#> [13] "BUF" "BUR" "BWI" "BZN" "CAE" "CAK" "CHS" "CLE" "CLT" "CMH" "CVG" "DAY"
#> [25] "DCA" "DEN" "DFW" "DSM" "DTW" "EGE" "EYW" "FLL" "GRR" "GSO" "GSP" "HDN"
#> [37] "HNL" "HOU" "IAD" "IAH" "ILM" "IND" "JAC" "JAX" "LAS" "LAX" "LGB" "MCI"
#> [49] "MCO" "MDW" "MEM" "MHT" "MIA" "MKE" "MSN" "MSP" "MSY" "MTJ" "OAK" "OKC"
#> [61] "OMA" "ORD" "ORF" "PBI" "PDX" "PHL" "PHX" "PIT" "PSE" "PSP" "PVD" "PWM"
#> [73] "RDU" "RIC" "ROC" "RSW" "SAN" "SAT" "SAV" "SBN" "SDF" "SEA" "SFO" "SJC"
#> [85] "SJU" "SLC" "SMF" "SNA" "SRQ" "STL" "STT" "SYR" "TPA" "TUL" "TYS" "XNA"
# Which months did plane N14228 fly in?
flights |> unique_where(tailnum == "N14228", month)
#>  [1]  1  2  3  4  5  6  7  8  9 10 12

Here we embrace condition because it’s passed to filter() and var because its passed to distinct(), arrange(), and pull().

We’ve made all these examples take a data frame as the first argument, but if you’re working repeatedly with the same data, it can make sense to hardcode it. For example, the following function always works with the flights dataset and always selects time_hour, carrier, and flight since they form the compound primary key that allows you to identify a row.

flights_sub <- function(rows, cols) {
  flights |> 
    filter({{ rows }}) |> 
    select(time_hour, carrier, flight, {{ cols }})
}

flights_sub(dest == "IAH", contains("time"))
#> # A tibble: 7,198 × 8
#>   time_hour           carrier flight dep_time sched…¹ arr_t…² sched…³ air_t…⁴
#>   <dttm>              <chr>    <int>    <int>   <int>   <int>   <int>   <dbl>
#> 1 2013-01-01 05:00:00 UA        1545      517     515     830     819     227
#> 2 2013-01-01 05:00:00 UA        1714      533     529     850     830     227
#> 3 2013-01-01 06:00:00 UA         496      623     627     933     932     229
#> 4 2013-01-01 07:00:00 UA         473      728     732    1041    1038     238
#> 5 2013-01-01 07:00:00 UA        1479      739     739    1104    1038     249
#> 6 2013-01-01 09:00:00 UA        1220      908     908    1228    1219     233
#> # … with 7,192 more rows, and abbreviated variable names ¹​sched_dep_time,
#> #   ²​arr_time, ³​sched_arr_time, ⁴​air_time

Data-masking vs tidy-selection

Sometimes you want to select variables inside a function that uses data-masking. For example, imagine you want to write a count_missing() that counts the number of missing observations in rows. You might try writing something like:

count_missing <- function(df, group_vars, x_var) {
  df |> 
    group_by({{ group_vars }}) |> 
    summarise(n_miss = sum(is.na({{ x_var }})))
}
flights |> 
  count_missing(c(year, month, day), dep_time)
#> Error in `group_by()` at ]8;line = 127:col = 2;file:///Users/hadleywickham/Documents/dplyr/dplyr/R/summarise.Rdplyr/R/summarise.R:127:2]8;;:
#> ℹ In argument: `..1 = c(year, month, day)`.
#> Caused by error:
#> ! `..1` must be size 336776 or 1, not 1010328.

This doesn’t work because group_by() uses data-masking, not tidy-selection. We can work around that problem by using the handy pick() which allows you to use use tidy-selection inside data-masking functions:

count_missing <- function(df, group_vars, x_var) {
  df |> 
    group_by(pick({{ group_vars }})) |> 
    summarise(n_miss = sum(is.na({{ x_var }})))
}
flights |> 
  count_missing(c(year, month, day), dep_time)
#> `summarise()` has grouped output by 'year', 'month'. You can override using
#> the `.groups` argument.
#> # A tibble: 365 × 4
#> # Groups:   year, month [12]
#>    year month   day n_miss
#>   <int> <int> <int>  <int>
#> 1  2013     1     1      4
#> 2  2013     1     2      8
#> 3  2013     1     3     10
#> 4  2013     1     4      6
#> 5  2013     1     5      3
#> 6  2013     1     6      1
#> # … with 359 more rows

Another convenient use of pick() is to make a 2d table of counts. Here we count using all the variables in the rows and columns, then use pivot_wider() to rearrange the counts into a grid:

# https://twitter.com/pollicipes/status/1571606508944719876
count_wide <- function(data, rows, cols) {
  data |> 
    count(pick(c({{ rows }}, {{ cols }}))) |> 
    pivot_wider(
      names_from = {{ cols }}, 
      values_from = n,
      names_sort = TRUE,
      values_fill = 0
    )
}
diamonds |> count_wide(clarity, cut)
#> # A tibble: 8 × 6
#>   clarity  Fair  Good `Very Good` Premium Ideal
#>   <ord>   <int> <int>       <int>   <int> <int>
#> 1 I1        210    96          84     205   146
#> 2 SI2       466  1081        2100    2949  2598
#> 3 SI1       408  1560        3240    3575  4282
#> 4 VS2       261   978        2591    3357  5071
#> 5 VS1       170   648        1775    1989  3589
#> 6 VVS2       69   286        1235     870  2606
#> # … with 2 more rows
diamonds |> count_wide(c(clarity, color), cut)
#> # A tibble: 56 × 7
#>   clarity color  Fair  Good `Very Good` Premium Ideal
#>   <ord>   <ord> <int> <int>       <int>   <int> <int>
#> 1 I1      D         4     8           5      12    13
#> 2 I1      E         9    23          22      30    18
#> 3 I1      F        35    19          13      34    42
#> 4 I1      G        53    19          16      46    16
#> 5 I1      H        52    14          12      46    38
#> 6 I1      I        34     9           8      24    17
#> # … with 50 more rows

While our examples have mostly focused on dplyr, tidy evaluation also underpins tidyr, and if you look at the pivot_wider() docs you can see that names_from uses tidy-selection.

Exercises

  1. Using the datasets from nyclights13, write functions that:

    1. Find all flights that were cancelled (i.e. is.na(arr_time)) or delayed by more than an hour.

      flights |> filter_severe()
    2. Counts the number of cancelled flights and the number of flights delayed by more than an hour.

      flights |> group_by(dest) |> summarise_severe()
    3. Finds all flights that were cancelled or delayed by more than a user supplied number of hours:

      flights |> filter_severe(hours = 2)
    4. Summarizes the weather to compute the minum, mean, and maximum, of a user supplied variable:

      weather |> summarise_weather(temp)
    5. Converts the user supplied variable that uses clock time (e.g. dep_time, arr_time, etc) into a decimal time (i.e. hours + minutes / 60).

      weather |> standardise_time(sched_dep_time)
  2. For each of the following functions list all arguments that use tidy evaluation and describe whether they use data-masking or tidy-select: distinct(), count(), group_by(), rename_with(), slice_min(), slice_sample().

  3. Generalize the following function so that you can supply any number of variables to count.

    count_prop <- function(df, var, sort = FALSE) {
      df |>
        count({{ var }}, sort = sort) |>
        mutate(prop = n / sum(n))
    }

Plot functions

Instead of returning a data frame, you might want to return a plot. Fortunately you can use the same techniques with ggplot2, because aes() is a data-masking function. For example, imagine that you’re making a lot of histograms:

diamonds |> 
  ggplot(aes(carat)) +
  geom_histogram(binwidth = 0.1)

diamonds |> 
  ggplot(aes(carat)) +
  geom_histogram(binwidth = 0.05)

Wouldn’t it be nice if you could wrap this up into a histogram function? This is easy as once you know that aes() is a data-masking function so that you need to embrace:

histogram <- function(df, var, binwidth = NULL) {
  df |> 
    ggplot(aes({{ var }})) + 
    geom_histogram(binwidth = binwidth)
}

diamonds |> histogram(carat, 0.1)

Note that histogram() returns a ggplot2 plot, so that you can still add on additional components if you want. Just remember to switch from |> to +:

diamonds |> 
  histogram(carat, 0.1) +
  labs(x = "Size (in carats)", y = "Number of diamonds")

More variables

It’s straightforward to add more variables to the mix. For example, maybe you want an easy way to eyeball whether or not a data set is linear by overlaying a smooth line and a straight line:

# https://twitter.com/tyler_js_smith/status/1574377116988104704

linearity_check <- function(df, x, y) {
  df |>
    ggplot(aes({{ x }}, {{ y }})) +
    geom_point() +
    geom_smooth(method = "loess", color = "red", se = FALSE) +
    geom_smooth(method = "lm", color = "blue", se = FALSE) 
}

starwars |> 
  filter(mass < 1000) |> 
  linearity_check(mass, height)
#> `geom_smooth()` using formula = 'y ~ x'
#> `geom_smooth()` using formula = 'y ~ x'

Or maybe you want an alternative to colored scatterplots for very large datasets where overplotting is a problem:

# https://twitter.com/ppaxisa/status/1574398423175921665
hex_plot <- function(df, x, y, z, bins = 20, fun = "mean") {
  df |> 
    ggplot(aes({{ x }}, {{ y }}, z = {{ z }})) + 
    stat_summary_hex(
      aes(colour = after_scale(fill)), # make border same colour as fill
      bins = bins, 
      fun = fun,
    )
}
diamonds |> hex_plot(carat, price, depth)

Combining with dplyr

Some of the most useful helpers combine a dash of dplyr with ggplot2. For example, if you might want to do a vertical bar chart where you automatically sort the bars in frequency order using fct_infreq(). Since the bar chart is vertical, we also need to reverse the usual order to get the highest values at the top:

sorted_bars <- function(df, var) {
  df |> 
    mutate({{ var }} := fct_rev(fct_infreq({{ var }}))) |> 
    ggplot(aes(y = {{ var }})) + 
    geom_bar()
}
diamonds |> sorted_bars(cut)

Or you could maybe you want to make it easy to draw a bar plot just for a subset of the data:

conditional_bars <- function(df, condition, var) {
  df |> 
    filter({{ condition }}) |> 
    ggplot(aes({{ var }})) + 
    geom_bar()
}

diamonds |> conditional_bars(cut == "Good", clarity)

You can also get creative and display data summaries in other way. For example, this code uses the axis labels to display the highest value. As you learn more about ggplot2, the power of your functions will continue to increase.

# https://gist.github.com/GShotwell/b19ef520b6d56f61a830fabb3454965b

fancy_ts <- function(df, val, group) {
  labs <- df |> 
    group_by({{group}}) |> 
    summarize(breaks = max({{val}}))
  
  df |> 
    ggplot(aes(date, {{val}}, group = {{group}}, color = {{group}})) +
    geom_path() +
    scale_y_continuous(
      breaks = labs$breaks, 
      labels = scales::label_comma(),
      minor_breaks = NULL,
      guide = guide_axis(position = "right")
    ) 
}

df <- tibble(
  dist1 = sort(rnorm(50, 5, 2)), 
  dist2 = sort(rnorm(50, 8, 3)),
  dist4 = sort(rnorm(50, 15, 1)),
  date = seq.Date(as.Date("2022-01-01"), as.Date("2022-04-10"), by = "2 days")
)
df <- pivot_longer(df, cols = -date, names_to = "dist_name", values_to = "value")

fancy_ts(df, value, dist_name)

Next we’ll discuss two more complicated cases: faceting and automatic labeling.

Faceting

Unfortunately programming with faceting is a special challenge, because faceting was implemented before we understood what tidy evaluation was and how it should work. so you have to learn a new syntax. When programming with facets, instead of writing ~ x, you need to write vars(x) and instead of ~ x + y you need to write vars(x, y). The only advantage of this syntax is that vars() uses tidy evaluation so you can embrace within it:

# https://twitter.com/sharoz/status/1574376332821204999

foo <- function(x) {
  ggplot(mtcars, aes(mpg, disp)) +
    geom_point() +
    facet_wrap(vars({{ x }}))
}
foo(cyl)

As with data frame functions, it can be useful to make your plotting functions tightly coupled to a specific dataset, or even a specific variable. For example, the following function makes it particularly easy to interactively explore the conditional distribution bill_length_mm from palmerpenguins dataset.

# https://twitter.com/yutannihilat_en/status/1574387230025875457
density <- function(colour, facets, binwidth = 0.1) {
  diamonds |> 
    ggplot(aes(carat, after_stat(density), colour = {{ colour }})) +
    geom_freqpoly(binwidth = binwidth) +
    facet_wrap(vars({{ facets }}))
}

density()
density(cut)
density(cut, clarity)

Labeling

Remember the histogram function we showed you earlier?

histogram <- function(df, var, binwidth = NULL) {
  df |> 
    ggplot(aes({{ var }})) + 
    geom_histogram(binwidth = binwidth)
}

Wouldn’t it be nice if we could label the output with the variable and the bin width that was used? To do so, we’re going to have to go under the covers of tidy evaluation and use a function from package we haven’t talked about before: rlang. rlang is a low-level package that’s used by just about every other package in the tidyverse because it implements tidy evaluation (as well as many other useful tools).

To solve the labeling problem we can use rlang::englue(). This works similarly to str_glue(), so any value wrapped in { } will be inserted into the string. But it also understands {{ }}, which automatically insert the appropriate variable name:

histogram <- function(df, var, binwidth) {
  label <- rlang::englue("A histogram of {{var}} with binwidth {binwidth}")
  
  df |> 
    ggplot(aes({{ var }})) + 
    geom_histogram(binwidth = binwidth) + 
    labs(title = label)
}

diamonds |> histogram(carat, 0.1)

You can use the same approach any other place that you might supply a string in a ggplot2 plot.

Exercises

  1. Build up a rich plotting function by incrementally implementing each of the steps below.
    1. Draw a scatterplot given dataset and x and y variables.

    2. Add a line of best fit (i.e. a linear model with no standard errors).

    3. Add a title.

Style

R doesn’t care what your function or arguments are called but the names make a big difference for humans. Ideally, the name of your function will be short, but clearly evoke what the function does. That’s hard! But it’s better to be clear than short, as RStudio’s autocomplete makes it easy to type long names.

Generally, function names should be verbs, and arguments should be nouns. There are some exceptions: nouns are ok if the function computes a very well known noun (i.e. mean() is better than compute_mean()), or accessing some property of an object (i.e. coef() is better than get_coefficients()). Use your best judgement and don’t be afraid to rename a function if you figure out a better name later.

# Too short
f()

# Not a verb, or descriptive
my_awesome_function()

# Long, but clear
impute_missing()
collapse_years()

R also doesn’t care about how you use white space in your functions but future readers will. Continue to follow the rules from #chp-workflow-style. Additionally, function() should always be followed by squiggly brackets ({}), and the contents should be indented by an additional two spaces. This makes it easier to see the hierarchy in your code by skimming the left-hand margin.

# missing extra two spaces
pull_unique <- function(df, var) {
df |> 
  distinct({{ var }}) |> 
  pull({{ var }})
}

# Pipe indented incorrectly
pull_unique <- function(df, var) {
  df |> 
  distinct({{ var }}) |> 
  pull({{ var }})
}

# Missing {} and all one line
pull_unique <- function(df, var) df |> distinct({{ var }}) |> pull({{ var }})

As you can see we recommend putting extra spaces inside of {{ }}. This makes it very obvious that something unusual is happening.

Exercises

  1. Read the source code for each of the following two functions, puzzle out what they do, and then brainstorm better names.

    f1 <- function(string, prefix) {
      substr(string, 1, nchar(prefix)) == prefix
    }
    f3 <- function(x, y) {
      rep(y, length.out = length(x))
    }
  2. Take a function that you’ve written recently and spend 5 minutes brainstorming a better name for it and its arguments.

  3. Make a case for why norm_r(), norm_d() etc would be better than rnorm(), dnorm(). Make a case for the opposite.

Summary

In this chapter you learned how to write functions for three useful scenarios: creating a vector, creating a data frames, or creating a plot. Along the way your saw many examples, which hopefully started to get your creative juices flowing, and gave you some ideas for where functions might help your analysis code.

We have only shown you the bare minimum to get started with functions and there’s much more to learn. A few places to learn more are:

In the next chapter, we’ll dive into some of the details of R’s vector data structures that we’ve omitted so far. These are not immediately useful by themselves, but are a necessary foundation for the following chapter on iteration which gives you further tools for reducing code duplication.