Body modification part 4: Adding runtime checks to an existing function

Problem: Can I augment an existing function with checking code for a function?

I’m still experimenting with function body modification, and I’ve downscaled my ambitions and thought about what a type check helper should look like.

My first attempt worked, but in a very roundabout fashion and had holes big enough to drive a truck through.

The previous post showed that the code to apply a function to another can get very obscure (and unreadable!). Keeping it simple makes it easier to understand and reason about.

I’ve decided for my purposes, functions-which-modify-functions should:

  • Be very R-like, and avoid introducing new syntax (via infix operators or overloading the assignment operator) just for the sake of it.
  • Use memoise::memoise() as a reasonable template for how they are called.

Why write a function to add checks to another function at all?

  1. To learn about functions.
  2. The original function may be in a package or not easily editable for other reasons.
  3. You want to avoid manually writing a wrapper that calls the original function, and also avoid having to ensure the function signatures match.
  4. You want to avoid writing all the boilerplate checks at the top of a function (for clarity)

It’s really only Point 1 that is motivating me for now.

Example function to be checked

orig_func <- function(a = 1L, b = 2, c='hello', ...) {
  cat(c, a+b, "\n")
}

add_checks()

The function for adding checks to another function is shown below.

It operates by:

  • taking a function as its first argument
  • all subsequent arguments are interpreted as boolean statements for checking the arguments
  • create a code block combining all these tests
  • add this code block above the body of the original function
  • return the augmented version of the function
#-----------------------------------------------------------------------------
#' add checks to an existing function and return a new function
#'
#' @param fun existing function passed in a symbol
#' @param ... list of checks to add in front of function body
#'
#' @return new function (with the same function signature as `fun`) and the same
#'         body as `fun` with a block of tests inserted at the start of the function
#-----------------------------------------------------------------------------
add_checks <- function(fun, ...) {
  
  # Capture all the tests and turn each one into a stopifnot() call
  checks <- rlang::exprs(...)
  for (i in seq(checks)) {
    checks[[i]] <- bquote(stopifnot(isTRUE(.(checks[[i]]))))
  }
  
  # Bind all these checks into a single block
  checks <- rlang::call2('{', splice(checks))
  
  # Concatentate the test block with the original body
  new_body <- bquote({
    .(checks)
    .(body(fun))
  })
  
  # create and return the new function
  rlang::new_function(args = formals(fun), body = new_body)
}

The following code creates an enhanced version of orig_func() which tests the following before execution:

  • a is an integer
  • b is non-negative
  • No more than 2 non-captured arguments are absorbed into the ... construct
new_func <- add_checks(orig_func, is_integer(a), b >= 0,  length(list(...)) < 3)
new_func
## function (a = 1L, b = 2, c = "hello", ...) 
## {
##     {
##         stopifnot(isTRUE(is_integer(a)))
##         stopifnot(isTRUE(b >= 0))
##         stopifnot(isTRUE(length(list(...)) < 3))
##     }
##     {
##         cat(c, a + b, "\n")
##     }
## }
## <environment: 0x7f88f5422400>
new_func()
new_func(a = 1.1)
new_func(b = -1)
new_func(a = 1L, d = 1, e = 1, f=1)
> new_func()
hello 3 

> new_func(a = 1.1)
Error: is_integer(a) is not TRUE

> new_func(b = -1)
Error: b >= 0 is not TRUE

> new_func(a = 1L, d = 1, e = 1, f=1)
Error: length(list(...)) < 3 is not TRUE

Conclusion

  • This is much less insane than the first attempt at adding checks to an existing function
  • Extensions to this idea:
    • Output more informative error messages e.g. include the function name in the stop message.
    • Add verification that the result of each test is a single, non-missing, non-NA boolean value.
    • Don’t just exit at the first error - instead, display all possible errors before stopping execution.
    • Write a similar function to check the return value of a function e.g. add_return_value_check().