13.6 Inheritance

S3 classes can share behaviour through a mechanism called inheritance. Inheritance is powered by three ideas:

  • The class can be a character vector. For example, the ordered and POSIXct classes have two components in their class:

    class(ordered("x"))
    #> [1] "ordered" "factor"
    class(Sys.time())
    #> [1] "POSIXct" "POSIXt"
  • If a method is not found for the class in the first element of the vector, R looks for a method for the second class (and so on):

    s3_dispatch(print(ordered("x")))
    #>    print.ordered
    #> => print.factor
    #>  * print.default
    s3_dispatch(print(Sys.time()))
    #> => print.POSIXct
    #>    print.POSIXt
    #>  * print.default
  • A method can delegate work by calling NextMethod(). We’ll come back to that very shortly; for now, note that s3_dispatch() reports delegation with ->.

    s3_dispatch(ordered("x")[1])
    #>    [.ordered
    #> => [.factor
    #>    [.default
    #> -> [ (internal)
    s3_dispatch(Sys.time()[1])
    #> => [.POSIXct
    #>    [.POSIXt
    #>    [.default
    #> -> [ (internal)

Before we continue we need a bit of vocabulary to describe the relationship between the classes that appear together in a class vector. We’ll say that ordered is a subclass of factor because it always appears before it in the class vector, and, conversely, we’ll say factor is a superclass of ordered.

S3 imposes no restrictions on the relationship between sub- and superclasses but your life will be easier if you impose some. I recommend that you adhere to two simple principles when creating a subclass:

  • The base type of the subclass should be that same as the superclass.

  • The attributes of the subclass should be a superset of the attributes of the superclass.

POSIXt does not adhere to these principles because POSIXct has type double, and POSIXlt has type list. This means that POSIXt is not a superclass, and illustrates that it’s quite possible to use the S3 inheritance system to implement other styles of code sharing (here POSIXt plays a role more like an interface), but you’ll need to figure out safe conventions yourself.

13.6.1 NextMethod()

NextMethod() is the hardest part of inheritance to understand, so we’ll start with a concrete example for the most common use case: [. We’ll start by creating a simple toy class: a secret class that hides its output when printed:

new_secret <- function(x = double()) {
  stopifnot(is.double(x))
  structure(x, class = "secret")
}

print.secret <- function(x, ...) {
  print(strrep("x", nchar(x)))
  invisible(x)
}

x <- new_secret(c(15, 1, 456))
x
#> [1] "xx"  "x"   "xxx"

This works, but the default [ method doesn’t preserve the class:

s3_dispatch(x[1])
#>    [.secret
#>    [.default
#> => [ (internal)
x[1]
#> [1] 15

To fix this, we need to provide a [.secret method. How could we implement this method? The naive approach won’t work because we’ll get stuck in an infinite loop:

`[.secret` <- function(x, i) {
  new_secret(x[i])
}

Instead, we need some way to call the underlying [ code, i.e. the implementation that would get called if we didn’t have a [.secret method. One approach would be to unclass() the object:

`[.secret` <- function(x, i) {
  x <- unclass(x)
  new_secret(x[i])
}
x[1]
#> [1] "xx"

This works, but is inefficient because it creates a copy of x. A better approach is to use NextMethod(), which concisely solves the problem of delegating to the method that would have been called if [.secret didn’t exist:

`[.secret` <- function(x, i) {
  new_secret(NextMethod())
}
x[1]
#> [1] "xx"

We can see what’s going on with sloop::s3_dispatch():

s3_dispatch(x[1])
#> => [.secret
#>    [.default
#> -> [ (internal)

The => indicates that [.secret is called, but that NextMethod() delegates work to the underlying internal [ method, as shown by the ->.

As with UseMethod(), the precise semantics of NextMethod() are complex. In particular, it tracks the list of potential next methods with a special variable, which means that modifying the object that’s being dispatched upon will have no impact on which method gets called next.

13.6.2 Allowing subclassing

When you create a class, you need to decide if you want to allow subclasses, because it requires some changes to the constructor and careful thought in your methods.

To allow subclasses, the parent constructor needs to have ... and class arguments:

new_secret <- function(x, ..., class = character()) {
  stopifnot(is.double(x))

  structure(
    x,
    ...,
    class = c(class, "secret")
  )
}

Then the subclass constructor can just call to the parent class constructor with additional arguments as needed. For example, imagine we want to create a supersecret class which also hides the number of characters:

new_supersecret <- function(x) {
  new_secret(x, class = "supersecret")
}

print.supersecret <- function(x, ...) {
  print(rep("xxxxx", length(x)))
  invisible(x)
}

x2 <- new_supersecret(c(15, 1, 456))
x2
#> [1] "xxxxx" "xxxxx" "xxxxx"

To allow inheritance, you also need to think carefully about your methods, as you can no longer use the constructor. If you do, the method will always return the same class, regardless of the input. This forces whoever makes a subclass to do a lot of extra work.

Concretely, this means we need to revise the [.secret method. Currently it always returns a secret(), even when given a supersecret:

`[.secret` <- function(x, ...) {
  new_secret(NextMethod())
}

x2[1:3]
#> [1] "xx"  "x"   "xxx"

We want to make sure that [.secret returns the same class as x even if it’s a subclass. As far as I can tell, there is no way to solve this problem using base R alone. Instead, you’ll need to use the vctrs package, which provides a solution in the form of the vctrs::vec_restore() generic. This generic takes two inputs: an object which has lost subclass information, and a template object to use for restoration.

Typically vec_restore() methods are quite simple: you just call the constructor with appropriate arguments:

vec_restore.secret <- function(x, to, ...) new_secret(x)
vec_restore.supersecret <- function(x, to, ...) new_supersecret(x)

(If your class has attributes, you’ll need to pass them from to into the constructor.)

Now we can use vec_restore() in the [.secret method:

`[.secret` <- function(x, ...) {
  vctrs::vec_restore(NextMethod(), x)
}
x2[1:3]
#> [1] "xxxxx" "xxxxx" "xxxxx"

(I only fully understood this issue quite recently, so at time of writing it is not used in the tidyverse. Hopefully by the time you’re reading this, it will have rolled out, making it much easier to (e.g.) subclass tibbles.)

If you build your class using the tools provided by the vctrs package, [ will gain this behaviour automatically. You will only need to provide your own [ method if you use attributes that depend on the data or want non-standard subsetting behaviour. See ?vctrs::new_vctr for details.

13.6.3 Exercises

  1. How does [.Date support subclasses? How does it fail to support subclasses?

  2. R has two classes for representing date time data, POSIXct and POSIXlt, which both inherit from POSIXt. Which generics have different behaviours for the two classes? Which generics share the same behaviour?

  3. What do you expect this code to return? What does it actually return? Why?

    generic2 <- function(x) UseMethod("generic2")
    generic2.a1 <- function(x) "a1"
    generic2.a2 <- function(x) "a2"
    generic2.b <- function(x) {
      class(x) <- "a1"
      NextMethod()
    }
    
    generic2(structure(list(), class = c("b", "a2")))