19 - Error Handling and Generation

Implement exception handling routines in R functions
module 4
week 5
programming
debugging
Author
Affiliations

This lecture, as the rest of the course, is adapted from the version Stephanie C. Hicks designed and maintained in 2021 and 2022. Check the recent changes to this file through the GitHub history.

Pre-lecture materials

Read ahead

Read ahead

Before class, you can prepare by reading the following materials:

  1. https://adv-r.hadley.nz/debugging

Acknowledgements

Material for this lecture was borrowed and adopted from

Learning objectives

Learning objectives

At the end of this lesson you will:

  • Create errors, warnings, and messages in R functions using the functions stop, stopifnot, warning, and message.
  • Understand the importance of providing useful error messaging to improve user experience with functions. However, these can also slow down code substantially.

Error Handling and Generation

What is an error?

Errors most often occur when code is used in a way that it is not intended to be used.

Example

For example adding two strings together produces the following error:

"hello" + "world"
Error in "hello" + "world": non-numeric argument to binary operator

The + operator is essentially a function that takes two numbers as arguments and finds their sum.

Since neither "hello" nor "world" are numbers, the R interpreter produces an error.

Errors will stop the execution of your program, and they will (hopefully) print an error message to the R console.

In R there are two other constructs which are related to errors:

  1. Warnings
  2. Messages

Warnings are meant to indicate that something seems to have gone wrong in your program that should be inspected.

Example

Here’s a simple example of a warning being generated:

as.numeric(c("5", "6", "seven"))
Warning: NAs introduced by coercion
[1]  5  6 NA

The as.numeric() function attempts to convert each string in c("5", "6", "seven") into a number, however it is impossible to convert "seven", so a warning is generated.

Execution of the code is not halted, and an NA is produced for "seven" instead of a number.

Messages simply print to the R console, though they are generated by an underlying mechanism that is similar to how errors and warning are generated.

Example

Here’s a small function that will generate a message:

f <- function() {
    message("This is a message.")
}

f()
This is a message.

Note that using message() is better than print() because users can suppress (censor) the messages if they want to with suppressMessages() as shown below. There’s super easy equivalent for print() as capture.output() is more complicated to use.

suppressMessages(f())

Also, something I like to do with messages is take advantage of the fact that it uses paste0() inside. That is, it is easy to combine our message with more information, such as the current time. Note the extra space before T from This.

f2 <- function() {
    message(Sys.time(), " This is a message.")
}

f2()
2023-09-28 11:31:20.438794 This is a message.

See https://research.libd.org/spatialLIBD/reference/registration_wrapper.html for example.

Generating Errors

There are a few essential functions for generating errors, warnings, and messages in R.

The stop() function will generate an error.

Example

Let’s generate an error:

stop("Something erroneous has occurred!")
Error in eval(expr, envir, enclos): Something erroneous has occurred!

If an error occurs inside of a function, then the name of that function will appear in the error message:

name_of_function <- function() {
    stop("Something bad happened.")
}

name_of_function()
Error in name_of_function(): Something bad happened.

As we saw in the previous lesson, we can combine stop() with an if() if we want to provide our users a more informative error message than the one they would get otherwise. For example, see https://github.com/LieberInstitute/qsvaR/blob/feb9a9e4f8a499baba76271de37ca39a9969f400/R/k_qsvs.R#L30-L32.

The stopifnot() function takes a series of logical expressions as arguments and if any of them are false an error is generated specifying which expression is false.

Example

Let’s take a look at an example:

error_if_n_is_greater_than_zero <- function(n) {
    stopifnot(n <= 0)
    n
}

error_if_n_is_greater_than_zero(5)
Error in error_if_n_is_greater_than_zero(5): n <= 0 is not TRUE
Note

I recommend using stopifnot() only with objects that are arguments to your function. Otherwise, users will get confusing error messages involving objects that they do not know how they were created.

error_if_n_squared <- function(n) {
    ## Ok use
    stopifnot(n <= 0)

    ## Create an internal object
    n_squared <- n^2

    ## Not ok, since we are using the internal object n_squared
    stopifnot(n_squared <= 10)
    n
}

error_if_n_squared(-2)
[1] -2
## This generates a confusing error message to our users
error_if_n_squared(-4)
Error in error_if_n_squared(-4): n_squared <= 10 is not TRUE

stopifnot() is mostly used at the beginning of functions to check our inputs. You might also want to use rlang::arg_match() https://rlang.r-lib.org/reference/arg_match.html to provide error messages that include hints when we have a set of allowed options.

fn <- function(x = c("foo", "bar")) {
    x <- rlang::arg_match(x)

    ## Known scenario 1
    if (x == "foo") {
        print("I know what to do here with 'x = foo'")
    }

    ## Known scenario 2
    if (x == "bar") {
        print("I know what to do here with 'x = bar'")
    }
}
fn("foo")
[1] "I know what to do here with 'x = foo'"
fn("zoo")
Error in `fn()`:
! `x` must be one of "foo" or "bar", not "zoo".
ℹ Did you mean "foo"?

The warning() function creates a warning, and the function itself is very similar to the stop() function. Remember that a warning does not stop the execution of a program (unlike an error.)

Example
warning("Consider yourself warned!")
Warning: Consider yourself warned!

Just like errors, a warning generated inside of a function will include the name of the function in which it was generated:

make_NA <- function(x) {
    warning("Generating an NA.")
    NA
}

make_NA("Sodium")
Warning in make_NA("Sodium"): Generating an NA.
[1] NA

Messages are simpler than errors or warnings; they just print strings to the R console.

You can issue a message with the message() function:

Example
message("In a bottle.")
In a bottle.

When to generate errors or warnings

Stopping the execution of your program with stop() should only happen in the event of a catastrophe - meaning only if it is impossible for your program to continue.

  • If there are conditions that you can anticipate that would cause your program to create an error, then you should document those conditions so whoever uses your software is aware.

An example includes:

  • Providing invalid arguments to a function. You could check this at the beginning of your program using stopifnot() so that the user can quickly realize something has gone wrong.

You can think of a function as kind of contract between you and the user:

  • if the user provides specified arguments, your program will provide predictable results.

Of course it’s impossible for you to anticipate all of the potential uses of your program.

It’s appropriate to create a warning when this contract between you and the user is violated.

A perfect example of this situation is the result of

as.numeric(c("5", "6", "seven"))
Warning: NAs introduced by coercion
[1]  5  6 NA

The user expects a vector of numbers to be returned as the result of as.numeric() but "seven" is coerced into being NA, which is not completely intuitive.

R has largely been developed according to the Unix Philosophy, which generally discourages printing text to the console unless something unexpected has occurred.

Languages that commonly run on Unix systems like C and C++ are rarely used interactively, meaning that they usually underpin computer infrastructure (computers “talking” to other computers).

Messages printed to the console are therefore not very useful since nobody will ever read them and it’s not straightforward for other programs to capture and interpret them.

In contrast, R code is frequently executed by human beings in the R console, which serves as an interactive environment between the computer and person at the keyboard.

If you think your program should produce a message, make sure that the output of the message is primarily meant for a human to read.

You should avoid signaling a condition or the result of your program to another program by creating a message.

How should errors be handled?

Imagine writing a program that will take a long time to complete because of a complex calculation or because you’re handling a large amount of data. If an error occurs during this computation then you’re liable to lose all of the results that were calculated before the error, or your program may not finish a critical task that a program further down your pipeline is depending on. If you anticipate the possibility of errors occurring during the execution of your program, then you can design your program to handle them appropriately.

The tryCatch() function is the workhorse of handling errors and warnings in R. The first argument of this function is any R expression, followed by conditions which specify how to handle an error or a warning. The last argument, finally, specifies a function or expression that will be executed after the expression no matter what, even in the event of an error or a warning.

Let’s construct a simple function I’m going to call beera that catches errors and warnings gracefully.

beera <- function(expr) {
    tryCatch(expr,
        error = function(e) {
            message("An error occurred:\n", e)
        },
        warning = function(w) {
            message("A warning occured:\n", w)
        },
        finally = {
            message("Finally done!")
        }
    )
}

This function takes an expression as an argument and tries to evaluate it. If the expression can be evaluated without any errors or warnings then the result of the expression is returned and the message Finally done! is printed to the R console. If an error or warning is generated, then the functions that are provided to the error or warning arguments are printed. Let’s try this function out with a few examples.

beera({
    2 + 2
})
Finally done!
[1] 4
beera({
    "two" + 2
})
An error occurred:
Error in "two" + 2: non-numeric argument to binary operator

Finally done!
beera({
    as.numeric(c(1, "two", 3))
})
A warning occured:
simpleWarning in doTryCatch(return(expr), name, parentenv, handler): NAs introduced by coercion

Finally done!

Notice that we’ve effectively transformed errors and warnings into messages.

As a real use case of tryCatch() check https://github.com/leekgroup/recount/blob/742c5ea7cc321729d6b3f03412d5829dd55e023e/R/download_retry.R#L40 which is based on the Bioconductor guidelines for querying data from the web https://contributions.bioconductor.org/querying-web-resources.html.

Now that you know the basics of generating and catching errors you’ll need to decide when your program should generate an error. My advice to you is to limit the number of errors your program generates as much as possible. Even if you design your program so that it’s able to catch and handle errors, the error handling process slows down your program by orders of magnitude. Imagine you wanted to write a simple function that checks if an argument is an even number. You might write the following:

is_even <- function(n) {
    n %% 2 == 0
}

is_even(768)
[1] TRUE
is_even("two")
Error in n%%2: non-numeric argument to binary operator

You can see that providing a string causes this function to raise an error. You could imagine though that you want to use this function across a list of different data types, and you only want to know which elements of that list are even numbers. You might think to write the following:

is_even_error <- function(n) {
    tryCatch(n %% 2 == 0,
        error = function(e) {
            FALSE
        }
    )
}

is_even_error(714)
[1] TRUE
is_even_error("eight")
[1] FALSE

This appears to be working the way you intended, however when applied to more data this function will be seriously slow compared to alternatives. For example I could check that n is numeric before treating n like a number:

is_even_check <- function(n) {
    is.numeric(n) && n %% 2 == 0
}

is_even_check(1876)
[1] TRUE
is_even_check("twelve")
[1] FALSE

Notice that by using is.numeric() before the “AND” operator (&&), the expression n %% 2 == 0 is never evaluated. This is a programming language design feature called “short circuiting.” The expression can never evaluate to TRUE if the left hand side of && evaluates to FALSE, so the right hand side is ignored.

To demonstrate the difference in the speed of the code, we will use the microbenchmark package to measure how long it takes for each function to be applied to the same data.

library(microbenchmark)
microbenchmark(sapply(letters, is_even_check))
Unit: microseconds
                           expr    min      lq     mean  median      uq     max neval
 sapply(letters, is_even_check) 46.224 47.7975 61.43616 48.6445 58.4755 167.091   100
microbenchmark(sapply(letters, is_even_error))
Unit: microseconds
                           expr     min       lq     mean   median       uq      max neval
 sapply(letters, is_even_error) 640.067 678.0285 906.3037 784.4315 1044.501 2308.931   100

The error catching approach is nearly 15 times slower!

Proper error handling is an essential tool for any software developer so that you can design programs that are error tolerant. Creating clear and informative error messages is essential for building quality software.

Pro-tip

One closing tip I recommend is to put documentation for your software online, including the meaning of the errors that your software can potentially throw. Often a user’s first instinct when encountering an error is to search online for that error message, which should lead them to your documentation!

Summary

  • Errors, warnings, and messages can be generated within R code using the functions stop, stopifnot, warning, and message.

  • Catching errors, and providing useful error messaging, can improve user experience with functions but can also slow down code substantially.

Post-lecture materials

Additional Resources

R session information

options(width = 120)
sessioninfo::session_info()
─ Session info ───────────────────────────────────────────────────────────────────────────────────────────────────────
 setting  value
 version  R version 4.3.1 (2023-06-16)
 os       macOS Ventura 13.6
 system   aarch64, darwin20
 ui       X11
 language (EN)
 collate  en_US.UTF-8
 ctype    en_US.UTF-8
 tz       America/New_York
 date     2023-09-28
 pandoc   3.1.5 @ /opt/homebrew/bin/ (via rmarkdown)

─ Packages ───────────────────────────────────────────────────────────────────────────────────────────────────────────
 package     * version date (UTC) lib source
 cli           3.6.1   2023-03-23 [1] CRAN (R 4.3.0)
 colorout      1.3-0   2023-09-28 [1] Github (jalvesaq/colorout@8384882)
 digest        0.6.33  2023-07-07 [1] CRAN (R 4.3.0)
 evaluate      0.21    2023-05-05 [1] CRAN (R 4.3.0)
 fastmap       1.1.1   2023-02-24 [1] CRAN (R 4.3.0)
 htmltools     0.5.6   2023-08-10 [1] CRAN (R 4.3.0)
 htmlwidgets   1.6.2   2023-03-17 [1] CRAN (R 4.3.0)
 jsonlite      1.8.7   2023-06-29 [1] CRAN (R 4.3.0)
 knitr         1.44    2023-09-11 [1] CRAN (R 4.3.0)
 rlang         1.1.1   2023-04-28 [1] CRAN (R 4.3.0)
 rmarkdown     2.24    2023-08-14 [1] CRAN (R 4.3.1)
 rstudioapi    0.15.0  2023-07-07 [1] CRAN (R 4.3.0)
 sessioninfo   1.2.2   2021-12-06 [1] CRAN (R 4.3.0)
 xfun          0.40    2023-08-09 [1] CRAN (R 4.3.0)
 yaml          2.3.7   2023-01-23 [1] CRAN (R 4.3.0)

 [1] /Library/Frameworks/R.framework/Versions/4.3-arm64/Resources/library

──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────