8 min read

Quasi-Quotation By Analogy

This post is one of a series of posts that I am doing about quasi-quotation in R. This post tries to explain quasi-quotation by analogy. Other posts in the series currently are the series introduction and quasi-quotation applications.

Warning: We are embarking on an extended analogy wherein the quasi-quotation tools in base R and the rlang package are compared to recipes.

Consider the following interesting recipe:

This recipe references other recipes. Most recipes aren’t like this, but I think most of us have seen this kind of thing. I want to call it a “meta-recipe.”

There are two fundamentally different types of “ingredients” in this recipe. Chopped butternuts are a normal, basic ingredient, but “White Cake II” is something different. We expect that if we turn to page 636, we will find a recipe for the cake which starts from basic ingredients.

Now imagine that you made this cake and your friend loved it. He asks for the recipe. If you give him the photo above, it would be fundamentally incomplete. One idea is to rewrite the “Maple Butternut Cake” recipe by copy-and-pasting the sub-recipes for the cake and frosting all into one place, so that we have a single self-contained recipe.

Doing this rewriting is the essence of the unquote operator in quasi-quotation.

Before we get to the meta-recipe in code, I want to develop the analogy step-by-step.

Expressions are Recipes

Normally, when you tell R how to bake a cake, it goes right to work.

cake <- flour + sugar + eggs
## Error in eval(expr, envir, enclos): object 'flour' not found

In this example, the code flour + sugar + eggs is an expression, or a recipe. When we run this, R will immediately try to evaluate the expression and store the result in a new object called cake. The code above shows an error because when R looked in the pantry for flour, it couldn’t find any.

objects()
## character(0)

The Environment is the Pantry

Lets stock the pantry. We will assign these objects with numbers that might represent the number of calories in a serving.

flour <- 20
eggs  <- 50
sugar <- 72

objects()
## [1] "eggs"  "flour" "sugar"

Evaluating is Baking

Now let’s try again to bake that cake:

cake <- flour + sugar + eggs

cake
## [1] 142

We have a cake! Apparently it has 142 calories. Not surprising, as cake is very tasty!

The Recipe versus the Cake

The big idea here is you don’t have to immediately bake the cake. In R there are language features and quotation functions which make it possible to write down recipes and bake them at a later time.

In service of this extended comparison, I am going to rename my own quoting function recipe as follows:

recipe <- rlang::expr

Note that I am using rlang::expr here, but the basic analogy is equally applicable to base quote or rlang::quo. The distinction is in which pantry to look in, and I won’t get into that in this post. Remember:

The recipe is to the cake as an expression is to the result of evaluating the expression.

Anyway, let’s write down the recipe for cake.

cake_recipe <- recipe(flour + sugar + eggs)

cake_recipe
## flour + sugar + eggs

Great, now we have the recipe written down. Note that when we type regular code into R, it immediately evaluates it. But when we type cake_recipe into the console it just prints the recipe – it doesn’t bake it. In our code snippets, R objects that represent food, like cake have a numerical value akin to calories, while unevaluated expressions, like cake_recipe, don’t have a numerical value. Recipes don’t have calories – read as many as you want, but you will still be hungry!

Evaluating is Baking Part 2

Now that we have saved our recipe, let’s bake it. This time I will alias the rlang:::eval_tidy function as bake so that I can extend the analogy in the code. Again, a version of this would work equally well with base eval or rlang::eval_bare.

bake <- eval

cake2 <- bake(cake_recipe)

cake2
## [1] 142

While we are at it, let’s make a frosting recipe, and bake it.

frosting_recipe <- recipe(sugar + butter + vanilla + milk)

frosting_recipe
## sugar + butter + vanilla + milk
frosting <- bake(frosting_recipe)
## Error in bake(frosting_recipe): object 'butter' not found

We got an error when we tried to ‘bake’ the frosting because we didn’t put any butter in the pantry. But note that writing down the recipe completed successfully! I mean, obviously, right? You don’t need to actually have butter in the pantry in order to write down a recipe that involves butter. But you DO need butter when you want to bake that recipe.

The Recipe versus the Cake Part 2

So what can you do with the expression (recipe) other than evaluate it (bake it)? You can pass it to a function. Imagine your friend really liked the cake and wants the recipe. Giving your friend a cake is very different from giving your friend the recipe for cake.

To make it easier to tell what is going on, our friend just prints out whatever you give him.

friend <- function(gift) {
  print(gift)
}

friend(cake)
## [1] 142
friend(cake_recipe)
## flour + sugar + eggs

Here is what it looks like when you give your friend a cake.

friend(cake)
## [1] 142
friend(flour + sugar + eggs)
## [1] 142
friend(bake(cake_recipe))
## [1] 142

All three of these look the same because the expression (recipe) is evaluated (baked) before your friend prints it out.

Here is what it looks like when you give your friend a recipe for a cake.

friend(cake_recipe)
## flour + sugar + eggs
friend(recipe(flour + sugar + eggs))
## flour + sugar + eggs

There is one more way to send your friend a recipe instead of a cake. Showing it requires a new friend that really REALLY demands to see the recipe.

friend2 <- function(recipe){
  recipe <- rlang::enexpr(recipe)
  
  print(recipe)
}

friend2(flour + sugar + eggs)
## flour + sugar + eggs

The rlang package has enquo, enexpr, and soon ensym. These en... variants all mean something like: go back and make an expression (recipe) out of what was inside the function call. Once more, let’s emphasize how even though the calls look the same, the friend function ends up with a cake, whereas the friend2 ends up with a recipe.

friend(flour + sugar + eggs)
## [1] 142
friend2(flour + sugar + eggs)
## flour + sugar + eggs

Dplyr functions are functions that work on a combination of regular objects and expressions. The data argument is a regular object, but most of the other arguments use enexpr type tricks to go back and get expressions from you. You don’t feel like you are passing in an expression … but you are.

Back to the Meta Recipe

It’s time to get back to the original recipe for Maple Butternut Cake that your friend wanted. Maybe take a look at that photo above again. This is a “meta-recipe” or “higher-level recipe” in that it is a recipe (expression) which refers to other recipes (expressions).

butternut_cake_recipe <- recipe((cake_recipe) + butternuts + (frosting_recipe))

friend(butternut_cake_recipe)
## (cake_recipe) + butternuts + (frosting_recipe)

Clearly this is the unsatisfying and incomplete. We previously wrote down cake_recipe and frosting_recipe from basic ingredients. We just need a way to replace the symbol cake_recipe with the full recipe.

Unquoting is for Re-Writing a Meta-Recipe

This is the core purpose of the unquoting operator in rlang: !! or UQ(). This operator allows us to swap in the sub-recipes (sub-expressions) so that we can re-write a full, complete recipe starting from basic ingredients.

full_butternut_cake_recipe <- recipe((!!cake_recipe) + butternuts + (!!frosting_recipe))

friend(full_butternut_cake_recipe)
## (flour + sugar + eggs) + butternuts + (sugar + butter + vanilla + 
##     milk)

Let’s swap in the sub-recipes with friend2 as well. In this case, it isn’t necessary to explicitly capture the expression with recipe because the friend2 function uses enexpr to capture whatever is inside the function call as a recipe (expression).

friend2((!!cake_recipe) + butternuts + (!!frosting_recipe))
## (flour + sugar + eggs) + butternuts + (sugar + butter + vanilla + 
##     milk)

Putting the Pieces Together in a Dplyr-Like Function

To put it all together, let’s write a function that works in a dplyr-like way. The frost_it function will take one regular object (a cake), and one expression (a recipe for frosting). It will then evaluate the expression, add it to the cake, and return the result. It will use enexpr so that it conceals the expression-nature of the frosting_recipe argument from the user.

frost_it <- function(cake, frosting_recipe) {
  frosting_recipe <- rlang::enexpr(frosting_recipe)
  
  #stock the pantry
  sugar <- 75
  butter <- 60
  vanilla <- 5
  milk <- 40
  
  frosting <- bake(frosting_recipe)
  
  frosted_cake <- cake + frosting
  frosted_cake
}

frost_it(cake, frosting_recipe = sugar + butter + vanilla + milk)
## [1] 322

We did it!

Wrapping Up

This analogy probably doesn’t capture everything, but I think it has helped me understand better what is going on with quasi-quotation, and I hope it will help you. One thing that I have found confusing is the distinction between evaluation and unquoting. Importantly, you only use the unquoting operator !! inside of quoting functions (like expr, quo, or … “recipe”) because it is for re-writing recipes. The end product will be another recipe (expression). By contrast, evaluating will result in a normal value – a cake!

R term Analogy term Example
Expression Recipe expr(flour + sugar + eggs)
Environment Pantry objects() # check objects in the environment
Evaluation Baking eval_tidy(cake_recipe)
Quasi-Quotation Expression Meta-Recipe !!cake_recipe + butternuts + !!frosting_recipe
Unquoting !!/UQ() Re-writing a Recipe (as above)