💻 🏦
Code You Can Bank On

Side effects

Amelia McNamara

Pure function

A function where:

  • the return value depends only on argument values

  • only change is the return value

Examples:

function(x, y) {
  x + y
}
cos(pi)

Side effects

A function where:

  • the return value can depend on “the outside universe”

  • there is a change in the “the outside universe”

Examples:

readr::read_csv("myfile.csv")
runif(1)

Side effects

There are two main types of side-effect:

  • those that give feedback to the user.
  • those that change some global state.

User feedback

User feedback

  • Signalling a condition, with message(), warning(), or stop().
  • Printing to the console with cat().
  • Drawing to the current graphics device with base graphics or grid.

Global state

  • Creating (or modifying) an existing binding with <-.
  • Modifying the search path by attaching a package with library().
  • Changing the working directory with setwd().
  • Modifying a file on disk with (e.g.) write.csv().
  • Changing a global option with options() or a base graphics parameter with gpar().
  • Setting the random seed with set.seed()
  • Installing a package.
  • Changing environment variables with Sys.setenv(), or indirectly via a function like Sys.setlocale().
  • Modifying a variable in an enclosing environment with assign() or <<- 😱
  • Modifying an object with reference semantics (like R6 or data.table).

More esoteric side-effects

Including

  • Detaching a package from the search path with detach() 😱
  • Changing the library path, where R looks for packages, with .libPaths() 😱
  • Changing the active graphics device with (e.g.) png() or dev.off().
  • Registering an S4 class, method, or generic with methods::setGeneric().
  • Modifying the internal .Random.seed

Why is this important?

  • Pure functions easier to test than functions with side effects.
  • Side effects (interactions with universe) take time (Shiny).
  • Functions with side effects should document the effects.
  • Side effects are not inherently bad (we do need to write to the file system), but they need extra care.

Practical advice

Try to separate tasks into pure functions and side effects:

  • easier to test the pure functions and side effects separately

  • use these functions in higher-level functions

Spooky actions

When working with side effects, try not to do any “spooky actions” (things the user wouldn’t expect).

  • deleting variables
  • saving files
  • loading packages with library()
  • installing packages with install.packages()
  • Deleting objects in the global environment with rm(list = ls())

We can make the notion of spooky action precise by thinking about trees. Code should only affect the tree beneath where it lives, so any action that reaches up, or across, the tree is a spooky action.

Avoiding spooky actions

If you feel you must do a spooky action,

  • parameterize the action
  • advertise the action with a clear name
  • ask for confirmation
  • advertise the side effects

The {usethis} does a lot of potentially-spooky things. But it advertises the action, asks for confirmation, and advertises the side effects.

Errors

  • The most common type of side-effect is the error condition.
  • Sometimes, error messages can be cryptic:

    seq[10]
    Error in seq[10] : object of type 'closure' is not subsettable
  • You can write error messages that make things clearer for:
    • developers who call your functions
    • end users

Making errors

There are many ways to create error messages for your R functions.

In base R, you might use if()/else() statements with functions like message(), warning() and stop() inside, or a stopifnot().

Of course, the folks who make the tidyverse have also been thinking about error messages.

In 2013, Hadley wickham created {assertthat} as a drop-in replacement for stopifnot(). People still use assertthat! But, the tidyverse always moves on.

The new coolness is {cli}, which was released in 2017.

{cli}

The {cli} package is technically for command line interfaces, so it is more general than just error messages. In fact, it is what is being used by {usethis}, {devtools} and {testthat} to give us the informative messages we get as we use them!

But, {cli} gets used in many situations where assertthat might have been previously.

I went looking for a repo that had a mixture of assertthat and cli, and found the {fishmecher} package. Check out center_of_mass.R.

(Actually, I don’t think they’re using cli quite right.)

Creating an error condition

An effective error condition has:

  1. predicate (logical expression used to identify condition)
  2. clear message for end user
  3. class name for developer
  4. more information for developer

Previous error-handling code had 1, and sometimes 2. But cli is really adding in the 3 and 4.

Using cli::cli_abort()

{cli} package

# predicate
if (y > 3) {
  cli::cli_abort(
    # message for end user
    c(
      "{.var y} cannot be greater than 3.", 
      x = "{.var y} is {.val {y}}."
    ),
    # class name
    class = "BLT_error_threshold",  
    # more information
    y = y     
  )
}

Predicate

Prefer:

  • simpler predicates and more error-conditions

Over:

  • complex predicates and fewer error-conditions

Finding simplest set of predicates is just as challenging as finding the “right” names for functions and arguments.

Message

Content:

  • how did we violate the predicate?

Formatting:

  • {cli} provides powerful formatting tools:

    • use curly-braces and a tag, e.g. {.var y}
    • use more curly-braces to interpolate, e.g. {.val {y}}
  • see cli inline-markup for more details.

Class name

  • A developer, calling your function, can use the class name to handle the error, if they want.

  • Convention:

    • "{package}_error_{description}"

Additional information

This “stuff” is also available to an error handler.

  • Provide the data that went into the predicate.

  • Provide name of variable, e.g. y = y.

  • Avoid reserved names: message, class, call, body, footer, trace, parent, use_cli_format.

Validation

If there will be an error, surface it quickly.

Validating the arguments to a function is one way to do this.

For example:

  • is this a data frame?
  • does this data frame have these columns?

Questions like these can be generalized into functions:

  • throw an error if you need to.
  • otherwise, return data argument invisibly.

Validator functions (validate.R)

validate_data_frame <- function(.data, call = rlang::caller_env()) {
  # predicate
  if (!is.data.frame(.data)) {
    cli::cli_abort(
      # information for user
      c(
        "Must supply a data frame",
        x = "You have supplied a {.cls {class(.data)}}."
      ),
      # class information for developers
      class = "BLT_error_data",
      # other information for developers
      class_data = class(.data),
      # tell user where we are calling from
      call = call
    )
  }
  
  invisible(.data)
}

Validator functions (validate.R)

validate_cols <- function(.data, cols_req, call = rlang::caller_env()) {

  cols_data <- names(.data)
  cols_missing <- cols_req[!(cols_req %in% cols_data)]

  # predicate
  if (length(cols_missing) > 0) {

    # helper function to format quantities
    # - see https://cli.r-lib.org/articles/pluralization.html
    qlen <- function(x) cli::qty(length(x))

    cli::cli_abort(
      # information for user
      c(
        "Data frame needs {qlen(cols_req)} column{?s}: {.var {cols_req}}",
        i = "Has {qlen(cols_data)} column{?s}: {.var {cols_data}}",
        x = "Missing {qlen(cols_missing)} column{?s}: {.var {cols_missing}}"
      ),
      # class information for developers
      class = "BLT_error_cols",
      # other information for developers
      cols_req = cols_req,
      cols_data = cols_data,
      # tell user where we are calling from
      call = call
    )
  }

  invisible(.data)
}

Validator functions

I don’t really know why there isn’t a package to wrap some of this up more neatly, because this seems like a common pattern.

Possibly influenced by the {ussie} package, which was the example package for a lot of the slides I’ve modified?

Use validators

  • usethis::use_package("cli")

  • review validate_data_frame() (call argument)

  • add validator-functions to perc_missing_tidy()

  • if we’re going to check the columns, we need something to compare to

  • we can make a “function” that is just the variable names we expect

Use validators

In columns.R

FRED_names <- function() {
  c("date", "series_id", "value", "realtime_start", "realtime_end")
}

Use validators

perc_missing_tidy <- function(dataset, variable){
  validate_data_frame(dataset)
  validate_cols(dataset, FRED_names())

  var <- rlang::enquo(variable)
  var_eval <- rlang::eval_tidy(var, data = dataset)
  na_stats <- imputeTS::statsNA(var_eval, print_only = FALSE)
  tibble_perc <- tibble::tibble(perc = readr::parse_number(x = na_stats$percentage_NAs))
  return(tibble_perc)
}

Document, load, check, commit

Now would be a good time to commit your changes

Git icon

Jason Long, CC BY 3.0 <https://creativecommons.org/licenses/by/3.0>, via Wikimedia Commons

Build a test

Now that we’ve added some functionality, we should test it. How do we add a test? What is an expect_xxx() function we can use?

We can go MUCH deeper with tests for these cli objects.

Make a function with (more) side effects

So far, our functions have been pretty pure. We did just add error messages to a function, but those were pretty easy to test with expect_error() and expect_no_error().

Let’s make one that is tougher. For this function, I want to write a wrapper around imputeTS::ggplot_na_distribution(). This function has the name ggplot in the title, but it doesn’t work like a normal ggplot() function (taking the data as the first argument, then aesthetics).

usethis::use_r()

Let’s start with adding a function skeleton,

BLT_ggplot_na_distribution <- function(data, mapping, ...){

}

And then write some documentation first, as is better practice.

Make a function with side effects

#' ggplot to visualization the distribution of missing values
#'
#' Wrapper around [imputeTS::ggplot_na_distribution]
#'
#' @param data Default dataset to use for a plot
#' @param mapping Set of aesthetic mappings to use for plot.
#' @param ... Other arguments passed on to methods
#'
#' ggplot_na_distribution() understands the x aesthetic.
#'
#' @returns a ggplot object
#' @export
#'
#' @examples
#' tomatoes |>
#' BLT_ggplot_na_distribution(mapping = aes(x = value))
BLT_ggplot_na_distribution <- function(data, mapping = aes(), ...){

}

Make a function with side effects

#' ggplot to visualization the distribution of missing values
#'
#' Wrapper around [imputeTS::ggplot_na_distribution]
#'
#' @param data Default dataset to use for a plot
#' @param mapping Set of aesthetic mappings to use for plot.
#' @param ... Other arguments passed on to methods
#'
#' ggplot_na_distribution() understands the x aesthetic.
#'
#' @returns a ggplot object
#' @export
#'
#' @examples
#' tomatoes |>
#' BLT_ggplot_na_distribution(value)
BLT_ggplot_na_distribution <- function(data = NULL, mapping = aes(), ...){
  var <- mapping$x
  var_eval <- rlang::eval_tidy(var, data = data)
  imputeTS::ggplot_na_distribution(var_eval, ...)

Document, load, check, commit

Now would be a good time to commit your changes

Git icon

Jason Long, CC BY 3.0 <https://creativecommons.org/licenses/by/3.0>, via Wikimedia Commons

🤔 What happened?

Error in `aes(value)`: could not find function "aes"

Dependencies!

We’re back to package dependencies. A couple options:

BLT_ggplot_na_distribution <- function(data = NULL, mapping = ggplot2::aes(), ...){

😬

Or, we can import aes() from ggplot2(). This is a neater solution!

Document, load, check, commit

Git icon

Jason Long, CC BY 3.0 <https://creativecommons.org/licenses/by/3.0>, via Wikimedia Commons

Snapshot tests

Designed for capturing side-effects:

  • error messages
  • plots

Be careful about accepting changes (don’t just accept).

Can be temperamental - not run on CRAN.

We will go through, using examples.

Managing global state

Leave no footprints.

Leave the global state how you found it, avoid surprises later:

  • packages loaded
  • also: options, environment variables

Loading {devtools} in .Rprofile changes the global state.

When we hit the “Knit” button, or the Quarto “Render” button:

  • it runs in a new R session
  • does not execute user’s .Rprofile

Using “self-removing footprints”

The {withr} package [@withr] gives us tools to:

  • modify global state
  • specify when to reverse the modification

Useful family of functions:

  • withr::local_*()
  • changes the state back when a scope is exited
  • if called within a function, normally when the function exits

When could this be useful?

CRAN is (rightly) particular about “leave no footprints”.

You may need:

  • withr::local_options(): change an option
  • withr::local_dir(): change the working directory
  • withr::local_tempfile(): path to a temporary file

Useful in testthat code and in R code.

Interactive example

write_read_vanish <- function(x) {
  
  tempfile <- withr::local_tempfile(fileext = ".rds")
  
  saveRDS(x, file = tempfile)
  xnew <- readRDS(tempfile)
  
  print(
    glue::glue("'{tempfile}' contained {xnew}.")
  )
  
  invisible(xnew)
}

Another interactive example

not_runif <- function(n, min = 0, max = 1) {

  withr::with_seed(314, runif(n, min = min, max = max))
}

Test a function with side effects

It’s much harder to write tests for functions that have side effects. But, there are some new helpers out there. vdiffr is what is being used to test ggplot2. It has the function expect_doppleganger() that can compare snapshots of figures.

Do you remember the usethis function that will make a test file for us?

 usethis::use_test()

Example

test_that("plots have known output", {

  tomato_NA_dist <- BLT_ggplot_na_distribution(tomatoes, mapping = aes(x = value))
  vdiffr::expect_doppelganger("tomato_NA_dist", tomato_NA_dist)
})
  • rerun tests

Example

Let’s see how this test could catch us. Change tomatoes to bacon in the BLT_ggplot_na_distribution() code.

  • save

  • rerun tests

  • Can use testthat::snapshot_review() to review snapshot changes.

Document, load, check, commit

Git icon

Jason Long, CC BY 3.0 <https://creativecommons.org/licenses/by/3.0>, via Wikimedia Commons

More helpful packages to write errors (and tests!)

There are lots of other packages out there to help you write better errors. For example, {checkmate} helps you reduce the lines of code you spend writing checks for your functions.

Example

fact <- function(n, method = "stirling") {
  if (length(n) != 1)
    stop("Argument 'n' must have length 1")
  if (!is.numeric(n))
    stop("Argument 'n' must be numeric")
  if (is.na(n))
    stop("Argument 'n' may not be NA")
  if (is.double(n)) {
    if (is.nan(n))
      stop("Argument 'n' may not be NaN")
    if (is.infinite(n))
      stop("Argument 'n' must be finite")
    if (abs(n - round(n, 0)) > sqrt(.Machine$double.eps))
      stop("Argument 'n' must be an integerish value")
    n <- as.integer(n)
  }
  if (n < 0)
    stop("Argument 'n' must be >= 0")
  if (length(method) != 1)
    stop("Argument 'method' must have length 1")
  if (!is.character(method) || !method %in% c("stirling", "factorial"))
    stop("Argument 'method' must be either 'stirling' or 'factorial'")

  if (method == "factorial")
    factorial(n)
  else
    sqrt(2 * pi * n) * (n / exp(1))^n
}

Example

vs.

fact <- function(n, method = "stirling") {
  assertCount(n)
  assertChoice(method, c("stirling", "factorial"))

  if (method == "factorial")
    factorial(n)
  else
    sqrt(2 * pi * n) * (n / exp(1))^n
}

More helpful packages to write errors (and tests!)

  • {vcr} to record and replay HTTP requests
  • {webmockr} to stub and set expectations on HTTP requests

…and even more in the Package Development and Maintenance CRAN Task View Proposal

Summary

The most-common side-effect is an error. Good design:

  • simple (as possible) predicate
  • clear message
  • add a class using naming convention, and additional information

Use snapshot tests to capture side-effects.

  • be very careful when accepting changes to snapshots.

Use withr::local_*() functions to “leave no footprints”.

Resources