20 Evaluation

20.1 Prerequisites


20.2 Evaluation Basics

  1. Q: Carefully read the documentation for source(). What environment does it use by default? What if you supply local = TRUE? How do you provide a custom environment?

    A: By default, source() uses the global environment, but another evaluation environment may also be chosen, by passing it to the local-argument. To use a local environment set local = TRUE.

    # create temporary, sourcable R script
    tmp_file <- tempfile()
    writeLines("print(x)", tmp_file)
    # set x globally
    x <- "global environment"
    env2 <- rlang::env(x = "specified envirionment")
    locate_evaluation <- function(file, local){
      x <- "local environment"
      source(file, local = local)
    # where will source() be evaluated?
    locate_evaluation(tmp_file, local = FALSE)  # default
    #> [1] "global environment"
    locate_evaluation(tmp_file, local = env2)
    #> [1] "specified envirionment"
    locate_evaluation(tmp_file, local = TRUE)
    #> [1] "local environment"
  2. Q: Predict the results of the following lines of code:

    eval(quote(eval(quote(eval(quote(2 + 2))))))
    eval(eval(quote(eval(quote(eval(quote(2 + 2)))))))
    quote(eval(quote(eval(quote(eval(quote(2 + 2))))))) 

    A: You can see the output of the code above here:

    #> [1] 4
    #> [1] 4
    #> eval(quote(eval(quote(eval(quote(2 + 2))))))

    Generally, 2 + 2 is first quoted as an expression and than evaluated to 4. When we nest calls to quote() and eval() more calls will be added to the AST, but the pattern of quoting and evaluating stays the same.

    # pattern: evaluate a quoted expression
          2 + 2))
    #> [1] 4
    lobstr::ast(eval(quote(eval(quote(eval(quote(2 + 2)))))))
    #> █─eval 
    #> └─█─quote 
    #>   └─█─eval 
    #>     └─█─quote 
    #>       └─█─eval 
    #>         └─█─quote 
    #>           └─█─`+` 
    #>             ├─2 
    #>             └─2

    When we wrap this expression in another eval() the 4 will be evaluated once more, but the result doesn’t change. When we quote it, no evaluation takes place and we capture the expression instead.

  3. Q: Fill in the function bodies below to re-implement get() using sym() and eval(), andassign() using sym(), expr(), and eval(). Don’t worry about the multiple ways of choosing an environment that get() and assign() support; assume that the user supplies it explicitly

    # name is a string
    get2 <- function(name, env) {}
    assign2 <- function(name, value, env) {}

    A: The reimplemantion of these two function using tidy evaluation is based on building an expression, which is then evaluated.

    get2 <- function(name, env = caller_env()) {
      name_sym <- sym(name)
      eval(name_sym, env)
    x <- 1
    #> [1] 1

    The implementation could be even more concise, if the user would provide an expression instead of a string:

    get3 <- function(name, env = caller_env()){
      eval(name, env)
    #> [1] 1

    To build the correct expression for the value assignment, we unquote using !!. Bang bang! :)

    assign2 <- function(name, value, env = caller_env()) {
      name_sym <- sym(name)
      assign_expr <- expr(!!name_sym <- !!value)
      eval(assign_expr, env)
    assign2("x", 4)
    #> [1] 4
  4. Q: Modify source2() so it returns the result of every expression, not just the last one. Can you eliminate the for loop?


    • keep in mind <- invisibly returns its value
    tmp_file <- tempfile()
    writeLines("x <- 1
            y <- 2
            y  # another comment
           ", tmp_file)
    source2 <- function(file, env = caller_env()){
      readLines(file) %>% 
        parse_exprs() %>%
        map(eval_tidy, env = env)
      # alternatively: 
      # for (i in seq_along(exprs)) eval(exprs[i], env)
    #> Error in .f(.x[[i]], ...): object 'y' not found
    #> [1] 4
    #> Error in eval(expr, envir, enclos): object 'y' not found
  5. Q: We can make base::local() slightly easier to understand by spreading it over multiple lines:

    local3 <- function(expr, envir = new.env()) {
      call <- substitute(eval(quote(expr), envir))
      eval(call, envir = parent.frame())

    Explain how local() works in words. (Hint: you might want to print(call) to help understand what substitute() is doing, and read the documentation to remind yourself what environment new.env() will inherit from.)


    local3 <- function(expr, envir = new.env()) {
      call <- substitute(eval(quote(expr), envir))
      eval(call, envir = parent.frame())
    foo <- local3({
      x <- 10
      x * 2
    #> eval(quote({
    #>     x <- 10
    #>     x * 2
    #> }), new.env())
    #> [1] 20

    substitute() only replaces the expr with the input and the environment (for the call to eval) by the relate

    # how does substitute work?
    • substitute opperates in function execution environment, it replaces the variables bound in this environments by their expression (expr becomes the input, and envir, becomes the environment passed to local3 (new.env() by default))
    # this is, what is happening afterwards
    eval(eval(quote({x <- 10; x * 2}), new.env()), parent.frame())
    #> [1] 20
    eval(quote({x <- 10; x * 2}), new.env())  # this is evaluated locally
    #> [1] 20
    eval(20, parent.frame())  # makes it available in the caller environment
    #> [1] 20

20.3 Quosures

  1. Q: Predict what evaluating each of the following quosures will return if evaluated.

    q1 <- new_quosure(expr(x), env(x = 1))
    #> <quosure>
    #> expr: ^x
    #> env:  0x48365e8
    q2 <- new_quosure(expr(x + !!q1), env(x = 10))
    #> <quosure>
    #> expr: ^x + (^x)
    #> env:  0x552a0e8
    q3 <- new_quosure(expr(x + !!q2), env(x = 100))
    #> <quosure>
    #> expr: ^x + (^x + (^x))
    #> env:  0x4dd29f0

    A: Each quosure will be evaluated in it’s own environment. This leads us to:

    #> [1] 1
    #> [1] 11
    #> [1] 111
  2. Q: Write an enenv() function that captures the environment associated with an argument. (Hint: this should only require two function calls.)

    A: A quosure captures both the expression and the environment. From a captured quosure, we can access the environment with the help of get_env().

    enenv <- function(x){
      q <- enquo(x)
    # Test
    capture_env <- function(x){
    #> <environment: R_GlobalEnv>
    capture_env(x)  # functions execution environment is captured
    #> <environment: 0x5c73ed0>

20.4 Data Masks

  1. Q: Why did I use a for loop in transform2() instead of map()? Consider transform2(df, x = x * 2, x = x * 2).

    A: Within the for-loop the evaluation of previous steps has already been assigned to .data, which makes the specification of chained transformations possible.

    transform2 <- function(.data, ...) {
      dots <- enquos(...)
      for (i in seq_along(dots)) {
        name <- names(dots)[[i]]
        dot <- dots[[i]]
        .data[[name]] <- eval_tidy(dot, .data)
    df <- data.frame(x = 1)
    transform2(df, x = x * 2, x = x * 2)  # (re-)transforms x
    #>   x
    #> 1 4
    transform2(df, x1 = x * 2, x2 = x1 * 2)  # stores intermediate variables
    #>   x x1 x2
    #> 1 1  2  4

    We see, that the computation (x * 2) * 2 has been taking place - the output of the first transformation has been used as input for the second transformation. This feature is not available, with map(), becaus it evaluates each element of the input seperately. This implies, that the individual transformations are independent of another and that intermediate computations are not available for the subsequent transformations.

    transform3 <- function(.data, ...) {
      dots <- enquos(...)
      dots %>% 
        map(eval_tidy, .data) %>% 
        dplyr::bind_cols(.data, .)
    transform3(df, x = x * 2, x = x * 2)  # bind_cols() corrects duplicate names
    #>   x x1 x2
    #> 1 1  2  2
    transform3(df, x1 = x * 2, x2 = x1 * 2)
    #> Error in .f(.x[[i]], ...): object 'x1' not found

    The repeated retransformation of columns is very useful in interactive data analysis and can also be used within dplyr::mutate(). Be a little careful with reusing the same name too often though, because this spreads out related transformations across multiple statements.

  2. Q: Here’s an alternative implementation of subset2():

    subset3 <- function(data, rows) {
      rows <- enquo(rows)
      eval_tidy(expr(data[!!rows, , drop = FALSE]), data = data)
    df <- data.frame(x = 1:3)
    subset3(df, x == 1)

    Compare and contrast subset3() to subset2(). What are its advantages and disadvantages?

    A: Let’s take a closer look at subset2() first:

    subset2 <- function(data, rows) {
      rows <- enquo(rows)
      rows_val <- eval_tidy(rows, data)
      data[rows_val, , drop = FALSE]

    We see, that there is an additional logical check, which is missing from subset3(). The here the logical condition rows is evaluated in the context of data, which results in a logical vector used for subsetting. Afterwards only [ needs to be used to return the subset.

    # subset2() evaluation
    (rows_val <- eval_tidy(quo(x == 1), df))
    #> [1]  TRUE FALSE FALSE
    df[rows_val, , drop = FALSE]
    #>   x
    #> 1 1

    With subset3() both of these steps occur in a single line. This means, that the subsetting is also evaluated in the context of the data mask.

    # subset3() evaluation
    eval_tidy(expr(df[x == 1, ,drop = FALSE]), df)
    #>   x
    #> 1 1

    This is shorter, but also less readable, because the evaluation and the subsetting take place in the same expression. It may also introduce unwanted errors, if the data mask should contain an element named data, because the object from the data mask takes precedence over argument of the function.

    df <- data.frame(x = 1:3, data = 1)
    subset2(df, x == 1)
    #>   x data
    #> 1 1    1
    subset3(df, x == 1)
    #> Error in data[~x == 1, , drop = FALSE]: incorrect number of dimensions
  1. Q: The following function implements the basics of dplyr::arrange(). Annotate each line with a comment explaining what it does. Can you explain why !!.na.last is strictly correct, but omitting the !! is unlikely to cause problems?

    arrange2 <- function(.df, ..., .na.last = TRUE) {
      args <- enquos(...)
      order_call <- expr(order(!!!args, na.last = !!.na.last))
      ord <- eval_tidy(order_call, .df)
      stopifnot(length(ord) == nrow(.df))
      .df[ord, , drop = FALSE]

    A: This function builds an expression, which contains the specified order()-call. The !!!-operator is used, which allows multiple arguments to be included (to break ties). Once the correct roworder is determined, numeric subsetting is used to return the rearranged data frame.

    arrange2 <- function(.df, ..., .na.last = TRUE) {
      args <- enquos(...)  # capture arguments, which determine order
      order_call <- expr(order(!!!args, na.last = !!.na.last))
      # `!!!`: unquote-splice arguments into order()
      # `!!.na.last`: pass option for treatment of NAs to order()
      # return expression-object
      ord <- eval_tidy(order_call, .df)    # evaluate order_call within .df
      stopifnot(length(ord) == nrow(.df))  # ensure that no rows are dropped
      .df[ord, , drop = FALSE]  # reorder rows by numeric subsetting

    By using !!.na.last the .na.last-argument is unquoted, when the order()-call is built. That way, the na.last-argeument is already correctly specified (typically TRUE, FALSE or NA).

    Without the unquoting, the expression would read na.last = .na.last. The value for .na.last would still have to be looked up and found. Because these computations take place inside of the functions execution environment (which contains .na.last), this is unlikely to cause problems.

    # the effect of unquoting .na.last
    .na.last <- FALSE
    expr(order(..., na.last = !!.na.last))
    #> order(..., na.last = FALSE)
    expr(order(..., na.last = .na.last))
    #> order(..., na.last = .na.last)

    PS: Putting breakpoints (browser()) inside these functions was really helpful to figure out, what’s going on inside of them.

20.5 Using tidy evaluation

  1. Q: I’ve included an alternative implementation of threshold_var() below. What makes it different to the approach I used above? What makes it harder?

    threshold_var2 <- function(df, var, val) {
      var <- ensym(var)
      subset2(df, `$`(.data, !!var) >= !!val)

    A: Lets compare this approach to the original implementation:

    threshold_var <- function(df, var, val) {
      var <- as_string(ensym(var))
      subset2(df, .data[[var]] >= !!val)

    We can see, that the symbol in no longer coerced to a string in threshold_var2(). Therefore $ instead of [[ is used for subsetting. Initially we suspected partial matching to work with $, but this seems to avoided, when the expression is tidily evaluated.

    The prefix call to $() is less common than infix-subsetting using [[, but ultimately both functions seem to behave the same.

    df <- data.frame(x = 1:10)
    threshold_var(df, x, 8)
    #>     x
    #> 8   8
    #> 9   9
    #> 10 10
    threshold_var2(df, x, 8)
    #>     x
    #> 8   8
    #> 9   9
    #> 10 10

20.6 Base evaluation

  1. Q: Why does this function fail?

    lm3a <- function(formula, data) {
      formula <- enexpr(formula)
      lm_call <- expr(lm(!!formula, data = data))
      eval(lm_call, caller_env())
    lm3a(mpg ~ disp, mtcars)$call
    #> Error in as.data.frame.default(data, optional = TRUE): cannot coerce class
    #> '"function"' to a data.frame

    A: In this function, lm_call is evaluated in the caller environment, which happens to be the global environment. In this environment, the name data is bound to utils::data. To fix the error, we can either set the evaluation environment to the functions execution environment or unquote the data argument when building the call to lm().

    # change evaluation environment
    lm3b <- function(formula, data) {
      formula <- enexpr(formula)
      lm_call <- expr(lm(!!formula, data = data))
      eval(lm_call, current_env())
    lm3b(mpg ~ disp, mtcars)$call
    #> lm(formula = mpg ~ disp, data = data)
    lm3b(mpg ~ disp, data)$call  #reproduces original error
    #> Error in as.data.frame.default(data, optional = TRUE): cannot coerce class
    #> '"function"' to a data.frame

    When we want to unquote an argument within a function, we first need to capture the user-input (by enenxpr()).

    # unquoting data-argument
    lm3c <- function(formula, data) {
      formula <- enexpr(formula)
      data_quo <- enexpr(data)
      lm_call <- expr(lm(!!formula, data = !!data_quo))
      eval(lm_call, caller_env())
    lm3c(mpg ~ disp, mtcars)$call
    #> lm(formula = mpg ~ disp, data = mtcars)
  2. Q: When model building, typically the response and data are relatively constant while you rapidly experiment with different predictors. Write a small wrapper that allows you to reduce duplication in the code below.

    lm(mpg ~ disp, data = mtcars)
    lm(mpg ~ I(1 / disp), data = mtcars)
    lm(mpg ~ disp * cyl, data = mtcars)

    A: In the wrapping function below, the response and the data were defined as default argument to the function. It would also be acceptable to “hardcode” them into the lm()-expression instead, but this way provides a little more flexibility.

    lm_wrap <- function(pred, resp = mpg, data = mtcars){
      # unquoting using base tools
      lm(substitute(resp ~ pred, environment()), data = data)
    #> (Intercept)        disp 
    #>     29.5999     -0.0412
    lm_wrap(I(1 / disp))$coef
    #> (Intercept)   I(1/disp) 
    #>        10.8      1557.7
    lm_wrap(disp * cyl)$coef
    #> (Intercept)        disp         cyl    disp:cyl 
    #>     49.0372     -0.1455     -3.4052      0.0159

    In practice, small wrappers like this can help keeping your scripts well organized and make it easy to see, what is being changed.

  3. Q: Another way to write resample_lm() would be to include the resample expression (data[sample(nrow(data), replace = TRUE), , drop = FALSE]) in the data argument. Implement that approach. What are the advantages? What are the disadvantages?

    A: We can take advantage of the lazy evaluation of function arguments, by moving the resampling step into the argument definition. The uses passes the data to the function, but only a permutation of this data (rsampled_data) will be used.

    resample_lm0 <- function(
      formula, data,
      resample_data = data[sample(nrow(data), replace = TRUE), , drop = FALSE],
      env = current_env()
    ) {
      formula <- enexpr(formula)
      lm_call <- expr(lm(!!formula, data = resample_data))
      eval(lm_call, env)
    df <- data.frame(x = 1:10, y = 5 + 3 * (1:10) + round(rnorm(10), 2))
    (resamp_lm1 <- resample_lm0(y ~ x, data = df))
    #> lm(y ~ x, data = resample_data)
    #> Call:
    #> lm(formula = y ~ x, data = resample_data)
    #> Coefficients:
    #> (Intercept)            x  
    #>        4.85         3.02
    #> lm(formula = y ~ x, data = resample_data)

    With this approach the evaluation needs to take place within the functions environments, because the resampled dataset (defined as a default argument) will only be available in the function environment.

    Overall, putting an essential part of the preprocessing outside of the functions body is not common practice in R. Compared to the unquoting-implementation (resample_lm1()), this approach captures the model-call in a more meaningful way.

20.7 Old exercises

  1. Q: Run this code in your head and predict what it will print. Confirm or refute your prediction by running the code in R.

    f <- function(...) {
      x <- "f"
      g(f = x, ...)
    g <- function(...) {
      x <- "g"
      h(g = x, ...)
    h <- function(...) {
    x <- "top"
    out <- f(top = x)
    purrr::map_chr(out, eval_tidy)
  2. Q: What happens if you use expr() instead of enexpr() inside of subset2()?

  3. Q: Improve subset2() to make it more like real base::subset():

    • Drop rows where subset evaluates to NA
    • Give a clear error message if subset doesn’t yield a logical vector
    • What happens if subset yields a vector that’s not the same as the number of rows in data? What do you think should happen?
  4. Q: The third argument in base::subset() allows you to select variables. It treats variable names as if they were positions. This allows you to do things like subset(mtcars, , -cyl) to drop the cylinder variable, or subset(mtcars, , disp:drat) to select all the variables between disp and drat. How does this work? I’ve made this easier to understand by extracting it out into its own function that uses tidy evaluation.

    select <- function(df, vars) {
      vars <- enexpr(vars)
      var_pos <- set_names(as.list(seq_along(df)), names(df))
      cols <- eval_tidy(vars, var_pos)
      df[, cols, drop = FALSE]
    select(mtcars, -cyl)
  5. Q: Here’s an alternative implementation of arrange():

    invoke <- function(fun, ...) do.call(fun, dots_list(...))
    arrange3 <- function(.data, ..., .na.last = TRUE) {
      args <- enquos(...)
      ords <- purrr::map(args, eval_tidy, data = .data)
      ord <- invoke(order, !!!ords, na.last = .na.last)
      .data[ord, , drop = FALSE]

    Describe the primary difference in approach compared to the function defined in the text.

    One advantage of this approach is that you could check each element of ... to make sure that input is correct. What property should each element of ords have?

  6. Q: Here’s an alternative implementation of subset2():

    subset3 <- function(data, rows) {
      eval_tidy(quo(data[!!enquo(rows), , drop = FALSE]), data = data)

    Use intermediate variables to make the function easier to understand, then explain how this approach differs to the approach in the text.

  7. Q: Implement a form of arrange() where you can request a variable to sorted in descending order using named arguments:

    arrange(mtcars, cyl, desc = mpg, vs)

    (Hint: The descreasing argument to order() will not help you. Instead, look at the definition of dplyr::desc(), and read the help for xtfrm().)

  8. Q: Why do you not need to worry about ambiguous argument names with ... in arrange()? Why is it a good idea to use the . prefix anyway?

  9. Q: What does transform() do? Read the documentation. How does it work? Read the source code for transform.data.frame(). What does substitute(list(...)) do?

  10. Q: Use tidy evaluation to implement your own version of transform(). Extend it so that a calculation can refer to variables created by transform, i.e. make this work:

    df <- data.frame(x = 1:3)
    transform(df, x1 = x + 1, x2 = x1 + 1)
    #> Error in eval(substitute(list(...)), `_data`, parent.frame()): object 'x1'
    #> not found
  11. Q: What does with() do? How does it work? Read the source code for with.default(). What does within() do? How does it work? Read the source code for within.data.frame(). Why is the code so much more complex than with()?

  12. Q: Implement a version of within.data.frame() that uses tidy evaluation. Read the documentation and make sure that you understand what within() does, then read the source code.

  1. Q: When model building, typically the response and data are relatively constant while you rapidly experiment with different predictors. Write a small wrapper that allows you to reduce duplication in this situation.

    pred_mpg <- function(resp, ...) {
    pred_mpg(~ disp)
    pred_mpg(~ I(1 / disp))
    pred_mpg(~ disp * cyl)
  2. Q: Another way to way to write boot_lm() would be to include the boostrapping expression (data[sample(nrow(data), replace = TRUE), , drop = FALSE]) in the data argument. Implement that approach. What are the advantages? What are the disadvantages?

  3. Q: To make these functions somewhat more robust, instead of always using the caller_env() we could capture a quosure, and then use its environment. However, if there are multiple arguments, they might be associated with different environments. Write a function that takes a list of quosures, and returns the common environment, if they have one, or otherwise throws an error.

  4. Q: Write a function that takes a data frame and a list of formulas, fitting a linear model with each formula, generating a useful model call.

  5. Q: Create a formula generation function that allows you to optionally supply a transformation function (e.g. log()) to the response or the predictors.

20.8 Deprecated evaluation basics

  1. Q: The code generated by source2() lacks source references. Read the source code for sys.source() and the help for srcfilecopy(), then modify source2() to preserve source references. You can test your code by sourcing a function that contains a comment. If successful, when you look at the function, you’ll see the comment and not just the source code.


    tmp_file <- tempfile()
    writeLines('x <- 1
    test_function <- function() {
    "source me!"  # testcomment
    }', tmp_file)
    file <- tmp_file
    source2 <- function(file, env = caller_env()){
      lines <- readLines(file)
      srcfile <- srcfilecopy(file, lines)
      parse(text = lines, srcfile = srcfile, keep.source = TRUE) %>% 
        map(eval_tidy, env = env) 
    #> [[1]]
    #> [1] 1
    #> [[2]]
    #> function() {
    #> "source me!"  # testcomment
    #> }
    #> <environment: 0x5606a60>
    #> Error in eval(expr, envir, enclos): object 'test_function' not found
    • the comment is still missing