Search code examples
revallazy-evaluationsubstitutionquote

How to use `substitute` and `quote` with nested functions in `R`?


I am aware of the other answers on substitute, quote, and eval (e.g., this answer). However, I am still confused about the following scenario. I will err on the site of verbosity for the steps below to ensure what I am trying to do is clear.

Suppose I have four functions (i.e., see below) that all take an expression, pass it around, and only the root one evaluates it.

f1 <- function(expr) {
    x <- "Hello!"
    do.call(eval, list(expr = expr))
}

f2 <- function(expr) {
    f1(expr)
}

f3 <- function(expr) {
    f2(expr)
}

f4 <- function(expr) {
    f3(expr)
}

Then, I am interested in passing an unquoted expression to any of f2, f3, or f4 that should not be evaluated right away. More specifically, I would like to call:

f4(print(paste0("f4: ", x)))
f3(print(paste0("f3: ", x)))
f2(print(paste0("f2: ", x)))

and observe the following output:

[1] "f4: Hello!"
[1] "f3: Hello!"
[1] "f2: Hello!"

However, calling, e.g., f4(print(paste0("f4: ", x))), right now will result in an error that x is not defined, which is, indeed, not defined until the environment of f1:

Error in paste0("x: ", x) : object 'x' not found

I can use substitute in f4 to get the parse tree of the expression, e.g.:

f4 <- function(expr) {
    f3(substitute(expr))
}

then call the function

f4(print(paste0("f4: ", x)))

and obtain:

[1] "f4: Hello!"
[1] "f4: Hello!"

The double output is probably because the argument of eval, i.e., expr in f1, is unquoted. Quoting it solves this early evaluation:

f1 <- function(expr) {
    x <- "Hello!"
    do.call(eval, list(expr = quote(expr)))
}

f4(print(paste0("f4: ", x)))
# [1] "f4: Hello!"

If I apply the same logic to, say, f3, e.g.:

f3 <- function(expr) {
    f2(substitute(expr))
}

calling f3 works as expected, i.e.:

f3(print(paste0("f3: ", x)))
# [1] "f3: Hello!"

but now calling f4 fails, with the output being expr.

f4(print(paste0("f4: ", x)))

At this point, I am not exactly sure how or if this is even possible to achieve. Of course, the simplest way would be to just pass a quoted expression to any of these functions. However, I am curious how to achieve this without quote.


Solution

  • I think it is easier if we leave the print() statement and focus on passing quoted expressions within nested functions.

    We need to do two things. First, capture the expression in each function. In base R we do this with substitute().

    Then we need to evaluate the captured expression before passing it to the next function (in which it will be captured again).

    In f1 we do not have this problem, since it is the last function in our chain.

    In f2 things get more tricky. Using eval() inside f1 will capture that as well, so we need to evaluate early. In base R we can do this with bquote() to create a call and use .() inside bquote() to evaluate an expression early. So we wrap f1() into eval(bquote()) and evaluate the captured expression early with .(cap_expr). We need the outer eval() because bquote() returns an unevaluated call.

    f1 <- function(expr) {
      cap_expr <- substitute(expr)
      x <- "Hello!"
      eval(cap_expr)
    }
    
    f2 <- function(expr) {
      cap_expr <- substitute(expr)
      eval(bquote(f1(.(cap_expr))))
    }
    
    f3 <- function(expr) {
      cap_expr <- substitute(expr)
      eval(bquote(f2(.(cap_expr))))
    }
    
    f4 <- function(expr) {
      cap_expr <- substitute(expr)
      eval(bquote(f3(.(cap_expr))))
    }
    
    f1(paste("f1:", x))
    #> [1] "f1: Hello!"
    f2(paste("f2:", x))
    #> [1] "f2: Hello!"
    f3(paste("f3:", x))
    #> [1] "f3: Hello!"
    f4(paste("f4:", x))
    #> [1] "f4: Hello!"
    

    The 'rlang' package offers a different set of tools which makes things a bit easier.

    Here we can capture an expression inside a function with enexpr(), and evaluate it with eval_tidy(). eval_tidy() supports argument splicing with the bang bang operator !! which allows us to evaluate the captured expression capt_expr early.

    Note that, normally when programming with 'rlang' we would use enquo() to capture an expression and its environment (called a quosure). But eval_tidy() evaluates this quosure in the environment in which it was captured, and there x doesn't exist. So we would need to change the environment or add the function environment to the caller environment as parent. Either way, in this case just capturing the expression with enexpr() is easier than using enquo().

    library(rlang)
    
    f1 <- function(expr) {
      cap_expr <- enexpr(expr)
      x <- "Hello!"
      eval_tidy(cap_expr)
    }
    
    f2 <- function(expr) {
      cap_expr <- enexpr(expr)
      eval_tidy(f1(!!cap_expr))
    }
    
    f3 <- function(expr) {
      cap_expr <- enexpr(expr)
      eval_tidy(f2(!!cap_expr))
    }
    
    f4 <- function(expr) {
      cap_expr <- enexpr(expr)
      eval_tidy(f3(!!cap_expr))
    }
    
    f1(paste("f1:", x))
    #> [1] "f1: Hello!"
    f2(paste("f2:", x))
    #> [1] "f2: Hello!"
    f3(paste("f3:", x))
    #> [1] "f3: Hello!"
    f4(paste("f4:", x))
    #> [1] "f4: Hello!"
    

    Created on 2023-02-23 by the reprex package (v2.0.1)