Body modification part 5: Adding runtime check of function return value

Problem: Can I augment an existing function with checking code for its return value?

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.

Yesterday’s post showed that a simple function could be used to add checks to an existing function.

This post is about writing a function to add a check for the return value of a function.

Example function to be checked

calc_value <- function(a=1L, b=2, c=3) {
  (a + b) / c
}

check_return_value()

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 return value with the name res
  • create a code block combining all these tests
  • create a complete call to the function with the result captured in res
  • create a new function that calls the original function, then checks the return value for any errors before returning it
  • return the augmented version of the function
#-----------------------------------------------------------------------------
#' add checks for the return value of a function
#'
#' @param fun existing function passed in a symbol
#' @param ... list of checks to add for the result of the function assigned to `res`
#'
#' @return new function (with the same function signature as `fun`) with tests
#'         added for the return value
#-----------------------------------------------------------------------------
check_return_value <- 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))
  
  # Create a call to the function
  fun_sym     <- rlang::enexpr(fun)
  call_to_fun <- rlang::call2(fun_sym, splice(syms(names(formals(fun)))))
  
  # Concatentate the call to the function with the checks
  new_body <- bquote({
    res <- .(call_to_fun)
    .(checks)
    return(res)
  })
  
  # create and return the new function
  rlang::new_function(args = formals(fun), body = new_body)
}
calc_value_checked_return <- check_return_value(calc_value, !is_na(res))

calc_value_checked_return
## function (a = 1L, b = 2, c = 3) 
## {
##     res <- calc_value(a, b, c)
##     {
##         stopifnot(isTRUE(!is_na(res)))
##     }
##     return(res)
## }
## <environment: 0x7fa4a6a31880>
> calc_value_checked_return()
[1] 1

> calc_value_checked_return(b = NA)  # the result of the calculation will be NA
Error: isTRUE(!is_na(res)) is not TRUE

> calc_value_checked_return(c = 0) # this will cause a divide by zero error -> Inf
[1] Inf

check_args()

As per yesterday’s post, a function can also have its arguments checked. Yesterday’s add_checks() function has been renamed to check_args() and repeated here.

#-----------------------------------------------------------------------------
#' 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
#-----------------------------------------------------------------------------
check_args <- 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)
}

Now add a check to ensure that the argument c is never 0. This is in addition to the checks for the return value of the function as a whole.

calc_value_checked_args_and_return <- check_args(calc_value_checked_return, c != 0)
calc_value_checked_args_and_return
## function (a = 1L, b = 2, c = 3) 
## {
##     {
##         stopifnot(isTRUE(c != 0))
##     }
##     {
##         res <- calc_value(a, b, c)
##         {
##             stopifnot(isTRUE(!is_na(res)))
##         }
##         return(res)
##     }
## }
## <environment: 0x7fa4a5b4d4e8>
> calc_value_checked_args_and_return()
[1] 1

> calc_value_checked_args_and_return(b = NA) # An error should be raised because of an NA return value
Error: isTRUE(!is_na(res)) is not TRUE

> calc_value_checked_args_and_return(c = 0) # An error should be raised because `c` should never be 0
Error: isTRUE(c != 0) is not TRUE

Conclusion

  • A combination of check_args() and check_return_value() means that assertions about the arguments to a function and the return value from a function can be checked for types, and these checks can be added to the original function to produce a new function
  • These functions aren’t currently pipe-friendly because of the way the fun argument is used and the way piping with %>% uses . for argument representation. Ideally the functions should work like:
new_func <- calc_value %>% 
  check_return_value(!is.na(res)) %>% 
  check_args(c != 0)