Home

A practical guide to vctrs-powered S3 programming

This post is inspired by Stuart Lee’s guide on S4, where the fun turtle example is used to demonstrate why S4 and the design principles in R. Now, this turtle seeks to migrate to the S3 playground. We will help this turtle to settle in S3, and even form a turtle team to travel simultaneously.

This guide focuses on the practical knowledge about S3, featuring the vctrs package–a modern take on S3.

library(vctrs)

Constructing a vector of turtles

The animated turtle can be decomposed into three components: current coordinates, orientations, and historical paths. These are the key data to represent the turtle on the move. The description probably doesn’t sound like a simple vector, but a list of elements. This is a so-called record-type vector that contains a named list of equal-sized vectors. It’s not a commonly used vector type in base R, and the typical example is POSIXlt. The record-style object can behave exactly like as a vector by concealing the data.frame-like underpinning.

We first provide a low-level constructor new_turtle() that declares the data fields and the desired types required for the object, and the class name, using vctrs::new_rcrd(). The type check is performed in the constructor to overcome the issue of no formal class definitions in S3.

new_turtle <- function(x = double(), y = double(), orientation = double(),
                       path = list()) {
  vec_assert(x, double())
  vec_assert(y, double())
  vec_assert(orientation, double())
  stopifnot(is.list(path))
  
  new_rcrd(list(
    "x" = x, "y" = y,
    "orientation" = orientation, 
    "path" = path
  ), class = "turtle")
}
new_turtle()
#> <turtle[0]>

We’ve got a prototype of turtle working by calling new_turtle(), an empty container with no turtles. When we supply arguments into new_turtle(), oops, this turtle refuses to hang out, because we forget to put cosmetics on them.

new_turtle(x = 0, y = 0, orientation = 0, path = list(data.frame(x = 0, y = 0)))
#> <turtle[1]>
#> Error: `format.turtle()` not implemented.

As the error suggests, a format() method is needed. This is the first method defined for the turtle class. It should return strings that succinctly describe the object, for example where to locate the turtle. To display turtles (unicode) nicely on the screen, vctrs::obj_print_data() is also defined. The turtle stops complaining and shows up.

format.turtle <- function(x, ...) {
  info <- sprintf("\U1F422 located at (%1.0f, %1.0f); facing %s degrees",
    field(x, "x"), field(x, "y"), field(x, "orientation"))
  paste(info, sep = "\n")
}

obj_print_data.turtle <- function(x) {
  cat(format(x), sep = "\n")
}

new_turtle(x = 0, y = 0, orientation = 0, path = list(data.frame(x = 0, y = 0)))
#> <turtle[1]>
#> 🐢 located at (0, 0); facing 0 degrees

Creating a user interface

The constructor new_turtle() is rather strict. A user-friendly interface needs exposing to easily create the turtle object.

  1. It’s unnecessary for users to input path. We remove the path option to simplify the interface. We’ll have methods available for users to manipulate turtle.
  2. We check the range of orientation. If the input is not valid, it should return an informative error.
  3. We coerce inputs to doubles in case of integer inputs.
validate_turtle <- function(orientation) {
  if (orientation > 360 || orientation < -360) {
    stop("`orientation` should fall into the range between -360 and 360.",
      call. = FALSE)
  }
}

turtle <- function(x = 0, y = 0, orientation = 0) {
  validate_turtle(orientation)
  x <- as.double(x)
  y <- as.double(y)
  orientation <- as.double(orientation)
  new_turtle(
    x = x, y = y, 
    orientation = orientation, 
    path = vec_split(data.frame(x = x, y = y), by = vec_seq_along(x))[["val"]])
}

We’re done with crafting a user-facing function. We now check if we can operate the turtle in a way that we usually work with other vectors, such as concatenating c() and subsetting [, with no methods defined currently. Everything works like a charm. A tibble can hold the turtle vector, ready for data analysis. It also aborts immediately with clear message when we subset out of the range and assign non-turtle to turtle.

x <- rep(turtle(), 3)
unique(x)
#> <turtle[1]>
#> 🐢 located at (0, 0); facing 0 degrees
x[2:3]
#> <turtle[2]>
#> 🐢 located at (0, 0); facing 0 degrees
#> 🐢 located at (0, 0); facing 0 degrees
x[4]
#> Error: Must index existing elements.
#> ✖ Can't subset position 4.
#> ℹ There are only 3 elements.
x[1] <- 42
#> Error: Can't cast <double> to <turtle>.
tibble::tibble(
  tutles = turtle(x = 1:3, y = rep(0, 3), orientation = seq(60, 180, 60))
)
#> # A tibble: 3 x 1
#>                                     tutles
#>                                   <turtle>
#> 1  🐢 located at (1, 0); facing 60 degrees
#> 2 🐢 located at (2, 0); facing 120 degrees
#> 3 🐢 located at (3, 0); facing 180 degrees

In the old days, it’s a pain to make sure a new record-type vector works properly. Many methods have to be defined for the class, to name a few: c(), length(), rep(), unique() and as.data.frame(). We can use sloop::s3_methods_class() to find all methods attached to POSIXlt, 34 in total.

sloop::s3_methods_class("POSIXlt")
#> # A tibble: 34 x 4
#>   generic class   visible source
#>   <chr>   <chr>   <lgl>   <chr> 
#> 1 [       POSIXlt TRUE    base  
#> 2 [[      POSIXlt TRUE    base  
#> 3 [[<-    POSIXlt TRUE    base  
#> 4 [<-     POSIXlt TRUE    base  
#> # … with 30 more rows

With vctrs, we can focus more on the class design itself and concerns less about the base operations. What’s behind the scene is that turtle is a subclassing of vctrs_rcrd and vctrs_vctr. When rep() is called on the turtle object, it looks for the first available rep() method in the class hierarchy, which is rep.vctrs_rcrd() indicated by =>, since it skips the undefined rep.turtle() (without *). The sloop::s3_dispatch() trace the search path down.

class(x)
#> [1] "turtle"     "vctrs_rcrd" "vctrs_vctr"
sloop::s3_dispatch(rep(turtle(), 3))
#>    rep.turtle
#> => rep.vctrs_rcrd
#>  * rep.vctrs_vctr
#>    rep.default
#>  * rep (internal)

Defining turtle’s travelling verbs

So far turtles are stationary. We’d like to move them around, for example to forward() and turn() turtles. We can write these verbs as simple functions, but here we’re going to create generics for them, since we will have a subclass of turtle later. The UseMethod() allows us to declare the S3 generic and dispatch on the first argument x. The second argument ... gives the methods freedom to take extra arguments, like steps and angle. However, ... in the forward.turtle() method is ignored, because we don’t capture and use them inside the function. We include ... in our methods, for the sake of consistence with the generics.

forward <- function(x, ...) {
  UseMethod("forward")
}

turn <- function(x, ...) {
  UseMethod("turn")
}

forward.turtle <- function(x, steps = 1, ...) {
  path <- field(x, "path")
  angle <- field(x, "orientation") * pi / 180
  x_dir <- steps * cos(angle)
  y_dir <- steps * sin(angle)
  new_x <- field(x, "x") + x_dir
  new_y <- field(x, "y") + y_dir
  new_path <- lapply(vec_seq_along(x), 
    function(i) vec_rbind(path[[i]], data.frame(x = new_x[i], y = new_y[i])))
  
  field(x, "x") <- new_x
  field(x, "y") <- new_y
  field(x, "path") <- new_path
  x
}

turn.turtle <- function(x, angle = 0, ...) {
  field(x, "orientation") <- field(x, "orientation") + angle
  x
}

We try out if our turtles are able to move in the direction as instructed. The forward() and turn() methods have been vectorised to have many turtles travel at the same time. The following code chunk shows two turtles start at origins and stop at different places.

library(magrittr)
two_turtles <- c(turtle(), turtle()) %>% 
  turn(angle = c(60, 0)) %>% 
  forward(steps = 3)
two_turtles
#> <turtle[2]>
#> 🐢 located at (2, 3); facing 60 degrees
#> 🐢 located at (3, 0); facing 0 degrees

We own the generics and define their methods for the turtle class. What happens if we apply forward() to other classes? Is the error message familiar to you? 😝 Because we haven’t write the forward() method for Date yet, and will not.

forward(Sys.Date())
#> Error in UseMethod("forward"): no applicable method for 'forward' applied to an object of class "Date"

Instead we will make the message more readable by defining forward.default(). If forward() cannot find the method available for the class, forward.default() will eventually be called.

forward.default <- function(x, ...) {
  cls <- class(x)[1]
  err <- sprintf("`forward()` doesn't know how to handle class `%s` yet", cls) 
  stop(err, call. = FALSE)
}
forward(Sys.Date())
#> Error: `forward()` doesn't know how to handle class `Date` yet

Subclassing for pen-holding turtles

We want our turtles to hold a pen, so that they can draw the path as they travel. Rather than creating a brand new class, we’ll subclass our turtle class in order to reuse what we created and make life easier. It’s worth revisiting the new_turtle() constructor to allow for subclassing. Two new arguments ... and class are introduced in the parent constructor new_turtle(). Class-specific attributes will be passed to the new class via .... Note that the class should be specified as a character vector in a specific-to-general order, that is turtle_with_pen (specific) and turtle (general).

new_turtle <- function(x = double(), y = double(), orientation = double(),
                       path = list(), ..., class = character()) {
  vec_assert(x, double())
  vec_assert(y, double())
  vec_assert(orientation, double())
  stopifnot(is.list(path))
  
  new_rcrd(list(
    "x" = x, "y" = y,
    "orientation" = orientation, 
    "path" = path
  ), ..., class = c(class, "turtle"))
}

Two classes share the same underlying data, but the subclass turtle_with_pen holds some metadata (or attributes) that describe how the path to be drawn in terms of colour and thickness.

turtle_with_pen <- function(x = turtle(), colour = "steelblue", thickness = 1,
                            on = FALSE) {
  stopifnot(inherits(x, "turtle"))
  data <- vec_data(x)
  new_turtle(
    x = data[["x"]], y = data[["y"]], orientation = data[["orientation"]],
    path = data[["path"]],
    "colour" = colour, "thickness" = thickness, "on" = on, 
    class = "turtle_with_pen")
}

turtle_with_pen(x)
#> <turtle_with_pen[3]>
#> 🐢 located at (0, 0); facing 0 degrees
#> 🐢 located at (0, 0); facing 0 degrees
#> 🐢 located at (0, 0); facing 0 degrees

When we print() our pen-holding turtles, we are no more satisfied with stationary turtles 🐢. We are interested in showing animated turtles instead. This can be done by associating print() method to turtle_with_pen.

print.turtle_with_pen <- function(x, ...) {
  if (attr(x, "on")) {
    path <- field(x, "path")
    path_tbl <- vec_rbind(
      !!!lapply(vec_seq_along(x), function(i) vec_cbind(
        !!!path[[i]],
        move = seq_len(vec_size(path[[i]])),
        id = vec_repeat(i, times = vec_size(path[[i]])))))
    plot <- ggplot2::ggplot(data = path_tbl) + 
      ggplot2::geom_path(ggplot2::aes(x, y), colour = attr(x, "colour"), 
        size = attr(x, "thickness")) +
      ggplot2::facet_wrap(~ id) +
      ggplot2::coord_fixed() +
      ggplot2::theme_void() + 
      gganimate::transition_reveal(move)
    print(plot)
  } else {
    NextMethod()
  }
}
turtle_with_pen(x)
#> <turtle_with_pen[3]>
#> 🐢 located at (0, 0); facing 0 degrees
#> 🐢 located at (0, 0); facing 0 degrees
#> 🐢 located at (0, 0); facing 0 degrees

It looks like stationary turtles are being displayed again. The reason is that the default on = FALSE directs to calling NextMethod(), which essentially calls print.turtle() (the next available method for the “turtle” class hierarchy). We should have switched them on in the first place. Our pen-holding turtles inherit forward() and turn() behaviours from turtle, and conduct a hexagonal walk.

turtle_with_pen(x, colour = "#006d2c", on = TRUE) %>% 
  turn(angle = 90) %>% 
  forward(steps = 3) %>%
  turn(angle = -60) %>% 
  forward(steps = 3) %>% 
  turn(angle = -60) %>% 
  forward(steps = 3) %>%
  turn(angle = -60) %>% 
  forward(steps = 3) %>%
  turn(angle = -60) %>% 
  forward(steps = 3) %>%
  turn(angle = -60) %>% 
  forward(steps = 3) %>%
  turn(angle = -60)

Ending

This post shows the S3 approach to the turtle graphics. For more structured reading on S3, Hadley’s Advanced R is highly recommended.