16.3 R6 versus S3

R6 is a profoundly different OO system from S3 and S4 because it is built on encapsulated objects, rather than generic functions. Additionally R6 objects have reference semantics, which means that they can be modified in place. These two big differences have a number of non-obvious consequences which we’ll explore here:

  • A generic is a regular function so it lives in the global namespace. An R6 method belongs to an object so it lives in a local namespace. This influences how we think about naming.

  • R6’s reference semantics allow methods to simultaneously return a value and modify an object. This solves a painful problem called “threading state”.

  • You invoke an R6 method using $, which is an infix operator. If you set up your methods correctly you can use chains of method calls as an alternative to the pipe.

These are general trade-offs between functional and encapsulated OOP, so they also serve as a discussion of system design in R versus Python.

16.3.1 Namespacing

One non-obvious difference between S3 and R6 is the space in which methods are found:

  • Generic functions are global: all packages share the same namespace.
  • Encapsulated methods are local: methods are bound to a single object.

The advantage of a global namespace is that multiple packages can use the same verbs for working with different types of objects. Generic functions provide a uniform API that makes it easier to perform typical actions with a new object because there are strong naming conventions. This works well for data analysis because you often want to do the same thing to different types of objects. In particular, this is one reason that R’s modelling system is so useful: regardless of where the model has been implemented you always work with it using the same set of tools (summary(), predict(), …).

The disadvantage of a global namespace is that it forces you to think more deeply about naming. You want to avoid multiple generics with the same name in different packages because it requires the user to type :: frequently. This can be hard because function names are usually English verbs, and verbs often have multiple meanings. Take plot() for example:

plot(data)       # plot some data
plot(bank_heist) # plot a crime
plot(land)       # create a new plot of land
plot(movie)      # extract plot of a movie

Generally, you should avoid methods that are homonyms of the original generic, and instead define a new generic.

This problem doesn’t occur with R6 methods because they are scoped to the object. The following code is fine, because there is no implication that the plot method of two different R6 objects has the same meaning:

data$plot()
bank_heist$plot()
land$plot()
movie$plot()

These considerations also apply to the arguments to the generic. S3 generics must have the same core arguments, which means they generally have non-specific names like x or .data. S3 generics generally need ... to pass on additional arguments to methods, but this has the downside that misspelled argument names will not create an error. In comparison, R6 methods can vary more widely and use more specific and evocative argument names.

A secondary advantage of local namespacing is that creating an R6 method is very cheap. Most encapsulated OO languages encourage you to create many small methods, each doing one thing well with an evocative name. Creating a new S3 method is more expensive, because you may also have to create a generic, and think about the naming issues described above. That means that the advice to create many small methods does not apply to S3. It’s still a good idea to break your code down into small, easily understood chunks, but they should generally just be regular functions, not methods.

16.3.2 Threading state

One challenge of programming with S3 is when you want to both return a value and modify the object. This violates our guideline that a function should either be called for its return value or for its side effects, but is necessary in a handful of cases.

For example, imagine you want to create a stack of objects. A stack has two main methods:

  • push() adds a new object to the top of the stack.
  • pop() returns the top most value, and removes it from the stack.

The implementation of the constructor and the push() method is straightforward. A stack contains a list of items, and pushing an object to the stack simply appends to this list.

new_stack <- function(items = list()) {
  structure(list(items = items), class = "stack")
}

push <- function(x, y) {
  x$items <- c(x$items, list(y))
  x
}

(I haven’t created a real method for push() because making it generic would just make this example more complicated for no real benefit.)

Implementing pop() is more challenging because it has to both return a value (the object at the top of the stack), and have a side-effect (remove that object from that top). Since we can’t modify the input object in S3 we need to return two things: the value, and the updated object.

pop <- function(x) {
  n <- length(x$items)
  
  item <- x$items[[n]]
  x$items <- x$items[-n]
  
  list(item = item, x = x)
}

This leads to rather awkward usage:

s <- new_stack()
s <- push(s, 10)
s <- push(s, 20)

out <- pop(s)
out$item
#> [1] 20
s <- out$x
s
#> $items
#> $items[[1]]
#> [1] 10
#> 
#> 
#> attr(,"class")
#> [1] "stack"

This problem is known as threading state or accumulator programming, because no matter how deeply the pop() is called, you have to thread the modified stack object all the way back to where it lives.

One way that other FP languages deal with this challenge is to provide a multiple assign (or destructuring bind) operator that allows you to assign multiple values in a single step. The zeallot package (Teetor 2018) provides multi-assign for R with %<-%. This makes the code more elegant, but doesn’t solve the key problem:

library(zeallot)

c(value, s) %<-% pop(s)
value
#> [1] 10

An R6 implementation of a stack is simpler because $pop() can modify the object in place, and return only the top-most value:

Stack <- R6::R6Class("Stack", list(
  items = list(),
  push = function(x) {
    self$items <- c(self$items, x)
    invisible(self)
  },
  pop = function() {
    item <- self$items[[self$length()]]
    self$items <- self$items[-self$length()]
    item
  },
  length = function() {
    length(self$items)
  }
))

This leads to more natural code:

s <- Stack$new()
s$push(10)
s$push(20)
s$pop()
#> [1] 20

I encountered a real-life example of threading state in ggplot2 scales. Scales are complex because they need to combine data across every facet and every layer. I originally used S3 classes, but it required passing scale data to and from many functions. Switching to R6 made the code substantially simpler. However, it also introduced some problems because I forgot to call to $clone() when modifying a plot. This allowed independent plots to share the same scale data, creating a subtle bug that was hard to track down.

16.3.3 Method chaining

The pipe, %>%, is useful because it provides an infix operator that makes it easy to compose functions from left-to-right. Interestingly, the pipe is not so important for R6 objects because they already use an infix operator: $. This allows the user to chain together multiple method calls in a single expression, a technique known as method chaining:

s <- Stack$new()
s$
  push(10)$
  push(20)$
  pop()
#> [1] 20

This technique is commonly used in other programming languages, like Python and JavaScript, and is made possible with one convention: any R6 method that is primarily called for its side-effects (usually modifying the object) should return invisible(self).

The primary advantage of method chaining is that you can get useful autocomplete; the primary disadvantage is that only the creator of the class can add new methods (and there’s no way to use multiple dispatch).

References

Teetor, Nathan. 2018. Zeallot: Multiple, Unpacking, and Destructuring Assignment. https://CRAN.R-project.org/package=zeallot.