TL;DR: I think I want a version of enquos() that doesn’t unwrap quosures. (I think ‘unwrap’ is the right term, but I’m not 100% sure.)
The problem I had before was to write a function which takes an expression and returns a function made from that expression. It needs to (A) capture unevaluated expressions, (B) not unwrap quosures, but (C) can be used with inject() if the user wants to use quosures.
Here it is in action:
library(rlang)
inject <- function(expr, env = caller_env()) {
eval_bare(enexpr(expr), env)
}
make_fn <- function(x) {
x <- substitute(x)
if (!is_quosure(x)) {
x <- new_quosure(x, caller_env())
}
as_function(x)
}
# With unquoted expression
# Use local() to make sure that correct env is captured.
e <- local({
a <- 1
f <- make_fn({ a + 10 })
environment()
})
e$f()
#> [1] 11
e$a <- 2
e$f()
#> [1] 12
# With injected quosure
e <- local({
a <- 1
x <- quo(a)
f <- inject(make_fn({ !!x + 10 }))
environment()
})
e$f()
#> [1] 11
e$a <- 2
e$f()
#> [1] 12
This works the way we want.
I want to be able to pass the expression through multiple function calls without lots of repeated code. Here’s a function that calls make_fn, but it doesn’t actually work:
wrap_make_fn_bad <- function(x) {
make_fn(x)
}
# Fails with unquoted expression
e <- local({
a <- 1
f <- wrap_make_fn_bad({ a + 10 })
environment()
})
e$f()
#> [1] 11
e$a <- 2
e$f() # Returns 11, but want this to return 12
#> [1] 11
# Fails with injected quosure
e <- local({
a <- 1
x <- quo(a)
a <- 2
f <- inject(wrap_make_fn_bad({ !!x + 10 }))
environment()
})
e$f() # Throws error
#> Error: Base operators are not defined for quosures.
#> Do you need to unquote the quosure?
#>
#> # Bad:
#> myquosure + rhs
#>
#> # Good:
#> !!myquosure + rhs
In order for wrap_make_fn to work the way we want, it needs some stuff at the top to make sure x is a quosure, and then to inject() the quosure when calling make_fn(). Here’s a version which does these things.
wrap_make_fn <- function(x) {
x <- substitute(x)
if (!is_quosure(x)) {
x <- new_quosure(x, caller_env())
}
inject(make_fn(!!x))
}
# Works with unquoted expression
e <- local({
a <- 1
f <- wrap_make_fn({ a + 10 })
environment()
})
e$f()
#> [1] 11
e$a <- 2
e$f()
#> [1] 12
e <- local({
a <- 1
x <- quo(a)
f <- inject(wrap_make_fn({ !!x + 10 }))
environment()
})
e$f()
#> [1] 11
e$a <- 2
e$f()
#> [1] 12
It’s a little annoying to put that code at the top of each function, so it would be nice to have one function that does that. But there’s a bigger problem.
I also want it to work with .... Here are the things I’m trying to do:
inject() if the user wants to use quosures.....make_fn was able to do A and B and C. wrap_make_fn showed how to also do D. But I’m having trouble figuring out how to also do E.
My end goal is to create a function that takes ..., turns all of those expressions into a list, and returns a function which evaluates all those expressions in their original environments. And it should be possible to inject quosures in there.
enquos() is kind of close – it does A, D, E, and F:
make_q_fns <- function(...) {
qs <- enquos(...)
function() lapply(qs, eval_tidy)
}
wrap_make_q_fns <- function(...) {
a <- 5
make_q_fns(..., {a + 10})
}
a <- 1
f <- wrap_make_q_fns({a + 10})
f()
#> [[1]]
#> [1] 11
#>
#> [[2]]
#> [1] 15
a <- 2
f()
#> [[1]]
#> [1] 12
#>
#> [[2]]
#> [1] 15
But it of course unwraps quosures, so it won’t work for my purposes.
Here’s my attempt at make_fns and wrap_make_fns, which do A, B, C, and E.
make_fns <- function(...) {
env <- caller_env()
# Note: substitute(...()) allows getting original expressions through multiple
# functions that pass along ..., but that doesn't seem to be helpful here.
# exprs <- as.list(substitute(...()))
exprs <- match.call(expand.dots = FALSE)$...
qs <- lapply(exprs, function(x) {
if (!is_quosure(x)) new_quosure(x, env)
else x
})
function() {
lapply(qs, eval_tidy)
}
}
e <- local({
a <- 1
f <- make_fns(a+10, a+20)
environment()
})
e$f()
#> [[1]]
#> [1] 11
#>
#> [[2]]
#> [1] 21
e$a <- 2
e$f()
#> [[1]]
#> [1] 12
#>
#> [[2]]
#> [1] 22
e <- local({
a <- 1
x <- quo(a)
f <- inject(make_fns(a+10, !!x+20))
environment()
})
e$f()
#> [[1]]
#> [1] 11
#>
#> [[2]]
#> [1] 21
e$a <- 2
e$f()
#> [[1]]
#> [1] 12
#>
#> [[2]]
#> [1] 22
But then there’s the question of how to allow D, passing through multiple functions. I think that this has the right behavior, but it’s really awkward.
I would like to do something like this, but it won’t work right:
wrap_make_fns_bad <- function(...) {
a <- 5
x <- quo(a)
inject(make_fns(..., a+10, !!x+20))
}
So I ended up doing this, but it’s really awkward. We want developers to be able to do this, but it’s an ugly pattern to copy:
wrap_make_fns <- function(...) {
env <- caller_env()
dot_exprs <- match.call(expand.dots = FALSE)$...
dot_exprs <- lapply(dot_exprs, function(x) {
if (!is_quosure(x)) new_quosure(x, env)
else x
})
a <- 5
x <- quo(a)
inject(do.call(make_fns, c(dot_exprs, list(quote({ a + 10 }), quote(!!x + 20)))))
}
e <- local({
a <- 1
f <- wrap_make_fns(a+10, a+20)
environment()
})
e$f()
#> [[1]]
#> [1] 11
#>
#> [[2]]
#> [1] 21
#>
#> [[3]]
#> [1] 15
#>
#> [[4]]
#> [1] 25
e$a <- 2
e$f()
#> [[1]]
#> [1] 12
#>
#> [[2]]
#> [1] 22
#>
#> [[3]]
#> [1] 15
#>
#> [[4]]
#> [1] 25
e <- local({
a <- 1
x <- quo(a)
f <- inject(wrap_make_fns(a+10, !!x+20))
environment()
})
e$f()
#> [[1]]
#> [1] 11
#>
#> [[2]]
#> [1] 21
#>
#> [[3]]
#> [1] 15
#>
#> [[4]]
#> [1] 25
e$a <- 2
e$f()
#> [[1]]
#> [1] 12
#>
#> [[2]]
#> [1] 22
#>
#> [[3]]
#> [1] 15
#>
#> [[4]]
#> [1] 25
Is there a better way to do this, so that I can have all of A-F?
As I noted in one of the comments, substitute(...()) will capture unevaluated expressions through multiple function calls. However, it does not give the original environments for the expressions. If there were a way to get the original environments, that would help solve my issue.