Conditions

Learning objectives:

  • What conditions are
  • How to use them

Introduction

What are conditions? Problems that happen in functions:

  • Error
  • Warning
  • Message

As a function author, one can signal them–that is, say there’s a problem.

As a function consumer, one can handle them–for example, react or ignore.

Signalling conditions

Types of conditions

Three types of conditions:

  • Errors. Problem arose, and the function cannot continue.
  • ⚠️ Warnings. Problem arose, but the function can continue, if only partially.
  • 💬 Messages. Something happened, and the user should know.

❌ Errors

How to throw errors

# with base R
stop("... in the name of love...")
#> Error: ... in the name of love...
# with rlang
rlang::abort("...before you break my heart...")
#> Error:
#> ! ...before you break my heart...
# with base R; without call
stop("... think it o-o-over...", call. = FALSE)
#> Error: ... think it o-o-over...

Composing error messages

  • Mechanics.
    • stop() pastes together arguments
some_val <- 1
stop("Your value is: ", some_val, call. = FALSE)
#> Error: Your value is: 1
  • abort() requires {glue}
some_val <- 1
rlang::abort(glue::glue("Your value is: {some_val}"))
#> Error:
#> ! Your value is: 1

⚠️ Warnings

May have multiple warnings per call

warn <- function() {
  warning("This is your first warning")
  warning("This is your second warning")
  warning("This is your LAST warning")
}

Print all warnings once call is complete.

warn()
#> Warning in warn(): This is your first warning
#> Warning in warn(): This is your second warning
#> Warning in warn(): This is your LAST warning

Like errors, warning() has

  • a call argument
  • an {rlang} analog
# base R
# ... with call (implicitly .call = TRUE)
warning("Warning")
#> Warning: Warning
# ... with call suppressed
warning("Warning", call. = FALSE)
#> Warning: Warning
# rlang
# note: call suppressed by default
rlang::warn("Warning")
#> Warning: Warning

(Hadley’s) advice on usage:

  • Err on the side of errors. In other words, error rather than warn.
  • But warnings make sense in a few cases:
    • Function is being deprecated. Warn that it is reaching end of life.
    • Function is reasonably sure to recover from issue.

💬 Messages

Mechanics:

  • Issued immediately
  • Do not have a call argument

Style:

Messages are best when they inform about:

  • Default arguments
  • Status updates of for functions used primarily for side-effects (e.g., interaction with web API, file downloaded, etc.)
  • Progress of long-running process (in the absence of a status bar).
  • Package loading message (e.g., attaching package, objects masked)

Ignoring conditions

A few ways:

  • try()
  • suppressWarnings()
  • suppressMessages()

try()

What it does:

  • Displays error
  • But continues execution after error
bad_log <- function(x) {
  try(log(x))
  10
}

bad_log("bad")
#> Error in log(x) : non-numeric argument to mathematical function
#> [1] 10

Better ways to react to/recover from errors:

  1. Use tryCatch() to “catch” the error and perform a different action in the event of an error.
  2. Set a default value inside the call. See below.
default <- NULL
try(default <- read.csv("possibly-bad-input.csv"), silent = TRUE)
#> Warning in file(file, "rt"): cannot open file 'possibly-bad-input.csv': No such
#> file or directory

suppressWarnings(), suppressMessages()

What it does:

  • Supresses all warnings (messages)
# suppress warnings (from our `warn()` function above)
suppressWarnings(warn())

# suppress messages
many_messages <- function() {
  message("Message 1")
  message("Message 2")
  message("Message 3")
}

suppressMessages(many_messages())

Handling conditions

Every condition has a default behavior:

  • ❌ Errors halt execution
  • ⚠️ Warnings are collected during execution and displayed in bulk after execution
  • 💬 Messages are displayed immediately

Condition handlers allow one to change that behavior (within the scope of a function).

Two handler functions:

  • tryCatch()
  • withCallingHandlers()
# try to run `code_to_try_to_run`
# if (error) condition is signalled, fun some other code
tryCatch(
  error = function(cnd) {
    # code to run when error is thrown
  },
  code_to_try_to_run
)

# try to `code_to_try_to_run`
# if condition is signalled, run code corresponding to condition type
withCallingHandlers(
  warning = function(cnd) {
    # code to run when warning is signalled
  },
  message = function(cnd) {
    # code to run when message is signalled
  },
  code_to_try_to_run
)

Condition objects

# catch a condition
cnd <- rlang::catch_cnd(stop("An error"))
# inspect it
str(cnd)
#> List of 2
#>  $ message: chr "An error"
#>  $ call   : language force(expr)
#>  - attr(*, "class")= chr [1:3] "simpleError" "error" "condition"

The standard components

  • message. The error message. To extract it, use conditionMessage(cnd).
  • call. The function call that triggered the condition. To extract it, use conditionCall(cnd).

But custom conditions may contain other components.

Exiting handlers

If a condition is signalled, this type of handler controls what code to run before exiting the function call.

f3 <- function(x) {
  tryCatch(
    # if error signalled, return NA
    error = function(cnd) NA,
    # try to run log
    log(x)
  )
}

f3("x")
#> [1] NA

When a condition is signalled, control moves to the handler and never returns to the original code.

tryCatch(
  message = function(cnd) "There",
  {
    message("Here")
    stop("This code is never run!")
  }
)
#> [1] "There"

The tryCatch() exit handler has one final argument: finally. This is run regardless of the condition of the original code. This is often used for clean-up.

# try to write text to disk
# if an error is signalled--for example, `path` does not exist
# or if no condition is signalled
# that is in both cases, the code block in `finally` is executed
path <- tempfile()
tryCatch(
  {
    writeLines("Hi!", path)
    # ...
  },
  finally = {
    # always run
    unlink(path)
  }
)

Calling handlers

Definition by verbal comparison:

  • With exit handlers, code exits the normal flow once a condition is signalled
  • With calling handlers, code continues in the normal flow once control is returned by the handler.

Definition by code comparison:

# with an exit handler, control moves to the handler once condition signalled and does not move back
tryCatch(
  message = function(cnd) cat("Caught a message!\n"), 
  {
    message("Someone there?")
    message("Why, yes!")
  }
)
#> Caught a message!
# with a calling handler, control moves first to the handler and the moves back to the main code
withCallingHandlers(
  message = function(cnd) cat("Caught a message!\n"), 
  {
    message("Someone there?")
    message("Why, yes!")
  }
)
#> Caught a message!
#> Someone there?
#> Caught a message!
#> Why, yes!

By default, conditions propagate

Let’s suppose that there are nested handlers. If a condition is signalled in the child, it propagates to its parent handler(s).

# Bubbles all the way up to default handler which generates the message
withCallingHandlers(
  message = function(cnd) cat("Level 2\n"),
  withCallingHandlers(
    message = function(cnd) cat("Level 1\n"),
    message("Hello")
  )
)
#> Level 1
#> Level 2
#> Hello
# Bubbles up to tryCatch
tryCatch(
  message = function(cnd) cat("Level 2\n"),
  withCallingHandlers(
    message = function(cnd) cat("Level 1\n"),
    message("Hello")
  )
)
#> Level 1
#> Level 2

But conditions can be muffled

If one wants to “muffle” the siginal, one needs to use rlang::cnd_muffle()

# Muffles the default handler which prints the messages
withCallingHandlers(
  message = function(cnd) {
    cat("Level 2\n")
    rlang::cnd_muffle(cnd)
  },
  withCallingHandlers(
    message = function(cnd) cat("Level 1\n"),
    message("Hello")
  )
)
#> Level 1
#> Level 2
# Muffles level 2 handler and the default handler
withCallingHandlers(
  message = function(cnd) cat("Level 2\n"),
  withCallingHandlers(
    message = function(cnd) {
      cat("Level 1\n")
      rlang::cnd_muffle(cnd)
    },
    message("Hello")
  )
)
#> Level 1

Call stacks

Call stacks of exiting and calling handlers differ.

Why?

Calling handlers are called in the context of the call that signalled the condition exiting handlers are called in the context of the call to tryCatch()

To see this, consider how the call stacks differ for a toy example.

# create a function
f <- function() g()
g <- function() h()
h <- function() message

# call stack of calling handlers
withCallingHandlers(f(), message = function(cnd) {
  lobstr::cst()
  rlang::cnd_muffle(cnd)
})
#> function (..., domain = NULL, appendLF = TRUE) 
#> {
#>     cond <- if (...length() == 1L && inherits(..1, "condition")) {
#>         if (nargs() > 1L) 
#>             warning("additional arguments ignored in message()")
#>         ..1
#>     }
#>     else {
#>         msg <- .makeMessage(..., domain = domain, appendLF = appendLF)
#>         call <- sys.call()
#>         simpleMessage(msg, call)
#>     }
#>     defaultHandler <- function(c) {
#>         cat(conditionMessage(c), file = stderr(), sep = "")
#>     }
#>     withRestarts({
#>         signalCondition(cond)
#>         defaultHandler(cond)
#>     }, muffleMessage = function() NULL)
#>     invisible()
#> }
#> <bytecode: 0x000002ba6a952b98>
#> <environment: namespace:base>
# call stack of exit handlers
tryCatch(f(), message = function(cnd) lobstr::cst())
#> function (..., domain = NULL, appendLF = TRUE) 
#> {
#>     cond <- if (...length() == 1L && inherits(..1, "condition")) {
#>         if (nargs() > 1L) 
#>             warning("additional arguments ignored in message()")
#>         ..1
#>     }
#>     else {
#>         msg <- .makeMessage(..., domain = domain, appendLF = appendLF)
#>         call <- sys.call()
#>         simpleMessage(msg, call)
#>     }
#>     defaultHandler <- function(c) {
#>         cat(conditionMessage(c), file = stderr(), sep = "")
#>     }
#>     withRestarts({
#>         signalCondition(cond)
#>         defaultHandler(cond)
#>     }, muffleMessage = function() NULL)
#>     invisible()
#> }
#> <bytecode: 0x000002ba6a952b98>
#> <environment: namespace:base>
tryCatch(f(), message = function(cnd) lobstr::cst())
#> function (..., domain = NULL, appendLF = TRUE) 
#> {
#>     cond <- if (...length() == 1L && inherits(..1, "condition")) {
#>         if (nargs() > 1L) 
#>             warning("additional arguments ignored in message()")
#>         ..1
#>     }
#>     else {
#>         msg <- .makeMessage(..., domain = domain, appendLF = appendLF)
#>         call <- sys.call()
#>         simpleMessage(msg, call)
#>     }
#>     defaultHandler <- function(c) {
#>         cat(conditionMessage(c), file = stderr(), sep = "")
#>     }
#>     withRestarts({
#>         signalCondition(cond)
#>         defaultHandler(cond)
#>     }, muffleMessage = function() NULL)
#>     invisible()
#> }
#> <bytecode: 0x000002ba6a952b98>
#> <environment: namespace:base>

Custom conditions

Motivation

The base::log() function provides a minimal error message.

log(letters)
#> Error in log(letters): non-numeric argument to mathematical function
log(1:10, base = letters)
#> Error in log(1:10, base = letters): non-numeric argument to mathematical function

One could make a more informative error message about which argument is problematic.

my_log <- function(x, base = exp(1)) {
  if (!is.numeric(x)) {
    rlang::abort(paste0(
      "`x` must be a numeric vector; not ", typeof(x), "."
    ))
  }
  if (!is.numeric(base)) {
    rlang::abort(paste0(
      "`base` must be a numeric vector; not ", typeof(base), "."
    ))
  }

  base::log(x, base = base)
}

Consider the difference:

my_log(letters)
#> Error in `my_log()`:
#> ! `x` must be a numeric vector; not character.
my_log(1:10, base = letters)
#> Error in `my_log()`:
#> ! `base` must be a numeric vector; not character.

Signalling

Create a helper function to describe errors:

abort_bad_argument <- function(arg, must, not = NULL) {
  msg <- glue::glue("`{arg}` must {must}")
  if (!is.null(not)) {
    not <- typeof(not)
    msg <- glue::glue("{msg}; not {not}.")
  }
  
  rlang::abort(
    "error_bad_argument", # <- this is the (error) class, I believe
    message = msg, 
    arg = arg, 
    must = must, 
    not = not
  )
}

Rewrite the log function to use this helper function:

my_log <- function(x, base = exp(1)) {
  if (!is.numeric(x)) {
    abort_bad_argument("x", must = "be numeric", not = x)
  }
  if (!is.numeric(base)) {
    abort_bad_argument("base", must = "be numeric", not = base)
  }

  base::log(x, base = base)
}

See the result for the end user:

my_log(letters)
#> Error in `abort_bad_argument()`:
#> ! `x` must be numeric; not character.
my_log(1:10, base = letters)
#> Error in `abort_bad_argument()`:
#> ! `base` must be numeric; not character.

Handling

Use class of condition object to allow for different handling of different types of errors

tryCatch(
  error_bad_argument = function(cnd) "bad_argument",
  error = function(cnd) "other error",
  my_log("a")
)
#> [1] "bad_argument"

But note that the first handler that matches any of the signal’s class, potentially in a vector of signal classes, will get control. So put the most specific handlers first.

Applications

See the sub-section in the book for excellent examples.

Resources