Side effects
A function where:
the return value depends only on argument values
only change is the return value
Examples:
A function where:
the return value can depend on “the outside universe”
there is a change in the “the outside universe”
Examples:
There are two main types of side-effect:
User feedback
message(), warning(), or stop().cat().<-.library().setwd().write.csv().options() or a base graphics parameter with gpar().set.seed()Sys.setenv(), or indirectly via a function like Sys.setlocale().assign() or <<- 😱Including
detach() 😱libPaths() 😱png() or dev.off().methods::setGeneric()..Random.seedTry 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
When working with side effects, try not to do any “spooky actions” (things the user wouldn’t expect).
library()install.packages()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.
If you feel you must do a spooky action,
The {usethis} does a lot of potentially-spooky things. But it advertises the action, asks for confirmation, and advertises the side effects.
Sometimes, error messages can be cryptic:
Error in seq[10] : object of type 'closure' is not subsettableThere 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.
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.)
An effective error condition has:
Previous error-handling code had 1, and sometimes 2. But cli is really adding in the 3 and 4.
cli::cli_abort(){cli} package
Prefer:
Over:
Finding simplest set of predicates is just as challenging as finding the “right” names for functions and arguments.
Content:
Formatting:
{cli} provides powerful formatting tools:
{.var y}{.val {y}}see cli inline-markup for more details.
A developer, calling your function, can use the class name to handle the error, if they want.
Convention:
"{package}_error_{description}"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.
If there will be an error, surface it quickly.
Validating the arguments to a function is one way to do this.
For example:
Questions like these can be generalized into functions:
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)
}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)
}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?
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
In columns.R
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)
}Now would be a good time to commit your changes
Jason Long, CC BY 3.0 <https://creativecommons.org/licenses/by/3.0>, via Wikimedia Commons
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.
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).
And then write some documentation first, as is better practice.
#' 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(), ...){
}#' 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, ...)Now would be a good time to commit your changes
🤔 What happened?
Error in `aes(value)`: could not find function "aes"
We’re back to package dependencies. A couple options:
😬
Or, we can import aes() from ggplot2(). This is a neater solution!
Jason Long, CC BY 3.0 <https://creativecommons.org/licenses/by/3.0>, via Wikimedia Commons
Designed for capturing side-effects:
Be careful about accepting changes (don’t just accept).
Can be temperamental - not run on CRAN.
We will go through, using examples.
Leave no footprints.
Leave the global state how you found it, avoid surprises later:
Loading {devtools} in .Rprofile changes the global state.
When we hit the “Knit” button, or the Quarto “Render” button:
.RprofileThe {withr} package [@withr] gives us tools to:
Useful family of functions:
withr::local_*()CRAN is (rightly) particular about “leave no footprints”.
You may need:
withr::local_options(): change an optionwithr::local_dir(): change the working directorywithr::local_tempfile(): path to a temporary fileUseful in testthat code and in R code.
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?
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)
})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.
Jason Long, CC BY 3.0 <https://creativecommons.org/licenses/by/3.0>, via Wikimedia Commons
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.
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
}vs.
…and even more in the Package Development and Maintenance CRAN Task View Proposal
The most-common side-effect is an error. Good design:
class using naming convention, and additional informationUse snapshot tests to capture side-effects.
Use withr::local_*() functions to “leave no footprints”.