Skip to contents

The R7 package is provides a new OOP system designed to be a successor to S3 and S4. It has been designed and implemented collaboratively by the RConsortium Object-Oriented Programming Working Group, which includes representatives from R-Core, BioConductor, RStudio/tidyverse, and the wider R community.

This vignette describes how R7 works, starting with an overview of classes, objects, generics, and methods, then going into the details of validation and properties.

Basics

We’ll start with an quick overview of the most important parts of R7: classes and objects, generics and methods, and the basics of method dispatch and inheritance.

Classes and objects

R7 classes have a formal definition that you create with new_class(). There are two arguments that you’ll use with almost every class:

  • The name of the class, supplied in the first argument.
  • The class properties, the data associated with each instance of the class. The easiest way to define properties is to supply a named list where the values define the valid types of the property.

The following code defines a simple pet class with two properties: a character name and a numeric age.

dog <- new_class("dog", properties = list(
  name = class_character,
  age = class_numeric
))
dog
#> <R7_class>
#> @ name  :  dog
#> @ parent: <R7_object>
#> @ properties:
#>  $ name: <character>          
#>  $ age : <integer> or <double>

R7 provides a number of built-in definitions that allow you to refer to existing base types that are not R7 classes. You can recognize these definitions because they all start with class_.

Note that I’ve assigned the return value of new_class() to an object with the same name as the class. This is important! That object represents the class and is what you use to construct instances of the class:

lola <- dog(name = "Lola", age = 11)
lola
#> <dog>
#>  @ name: chr "Lola"
#>  @ age : num 11

Once you have an R7 object, you can get and set properties using @:

lola@age <- 11
lola@age
#> [1] 11

R7 automatically validates the type of the property using the type supplied in new_class():

lola@age <- "twelve"
#> Error: <dog>@age must be <integer> or <double>, not <character>

Given an object, you can retrieves its class R7_class():

R7_class(lola)
#> <R7_class>
#> @ name  :  dog
#> @ parent: <R7_object>
#> @ properties:
#>  $ name: <character>          
#>  $ age : <integer> or <double>

R7 objects also have an S3 class(). This is used for compatibility with existing S3 generics and you can learn more about it in vignette("compatibility").

class(lola)
#> [1] "dog"       "R7_object"

Generics and methods

R7, like S3 and S4, is built around the idea of generic functions, or generics for short. A generic defines an interface, which uses a different implementation depending on the class of one or more arguments. The implementation for a specific class is called a method, and the generic finds that appropriate method by performing method dispatch.

Use new_generic() to create a R7 generic. In its simplest form, it only needs two arguments: the name of the generic (used in error messages) and the name of the argument used for method dispatch:

speak <- new_generic("speak", "x")

Like with new_class(), you should always assign the result of new_generic() to an variable with the same name as the first argument.

Once you have a generic, you can register methods for specific classes with method(generic, class) <- implementation.

method(speak, dog) <- function(x) {
  "Woof"
}

Once the method is registered, the generic will use it when appropriate:

speak(lola)
#> [1] "Woof"

Let’s define another class, this one for cats, and define another method for speak():

cat <- new_class("cat", properties = list(
  name = class_character,
  age = class_double
))
method(speak, cat) <- function(x) {
  "Meow"
}

fluffy <- cat(name = "Fluffy", age = 5)
speak(fluffy)
#> [1] "Meow"

You get an error if you call the generic with a class that doesn’t have a method:

speak(1)
#> Error: Can't find method for generic `speak()` with dispatch classes:
#> - x: double

Method dispatch and inheritance

The cat and dog classes share the same properties, so we could use a common parent class to extract out the duplicated specification. We first define the parent class:

pet <- new_class("pet", 
  properties = list(
    name = class_character,
    age = class_numeric
  )
) 

Then use the parent argument to new_class:

cat <- new_class("cat", parent = pet)
dog <- new_class("dog", parent = pet)

cat
#> <R7_class>
#> @ name  :  cat
#> @ parent: <pet>
#> @ properties:
#>  $ name: <character>          
#>  $ age : <integer> or <double>
dog
#> <R7_class>
#> @ name  :  dog
#> @ parent: <pet>
#> @ properties:
#>  $ name: <character>          
#>  $ age : <integer> or <double>

Because we have created new classes, we need to recreate the existing lola and fluffy objects:

lola <- dog(name = "Lola", age = 11)
fluffy <- cat(name = "Fluffy", age = 5)

Method dispatch takes advantage of the hierarchy of parent classes: if a method is not defined for a class, it will try the method for the parent class, and so on until it finds a method or gives up with an error. This inheritance is a powerful mechanism for sharing code across classes.

describe <- new_generic("describe", "x")
method(describe, pet) <- function(x) {
  paste0(x@name, " is ", x@age, " years old")
}
describe(lola)
#> [1] "Lola is 11 years old"
describe(fluffy)
#> [1] "Fluffy is 5 years old"

method(describe, dog) <- function(x) {
  paste0(x@name, " is a ", x@age, " year old dog")
}
describe(lola)
#> [1] "Lola is a 11 year old dog"
describe(fluffy)
#> [1] "Fluffy is 5 years old"

You can define a fallback method for any R7 object by registering a method for R7_object:

method(describe, R7_object) <- function(x) {
  "An R7 object"
}

cocktail <- new_class("cocktail", 
  properties = list(
    ingredients = class_character
  )
)
martini <- cocktail(ingredients = c("gin", "vermouth"))
describe(martini)
#> [1] "An R7 object"

Printing a generic will show you which methods are currently defined:

describe
#> <R7_generic> describe(x, ...) with 3 methods:
#> 1: method(describe, pet)
#> 2: method(describe, dog)
#> 3: method(describe, R7_object)

And you can use method() to retrieve the implementation of one of those methods:

method(describe, pet)
#> <R7_method> method(describe, pet)
#> function (x) 
#> {
#>     paste0(x@name, " is ", x@age, " years old")
#> }
#> <bytecode: 0x564ee55ba9b0>

Learn more about method dispatch in vignette("dispatch").

Validation

R7 classes can have an optional validator that checks that the values of the properties are OK. A validator is a function that takes the object (called self) and returns NULL if its valid or returns a character vector listing the problems.

Basics

In the following example we create a range class that enforces that @start and @end are single numbers, and that @start is less than @end:

range <- new_class("range",
  properties = list(
    start = class_double,
    end = class_double
  ),
  validator = function(self) {
    if (length(self@start) != 1) {
      "@start must be length 1"
    } else if (length(self@end) != 1) {
      "@end must be length 1"
    } else if (self@end < self@start) {
      sprintf(
        "@end (%i) must be greater than or equal to @start (%i)",
        self@end,
        self@start
      )
    }
  }
)

You can typically write a validator as a series of if-else statements, but note that the order of the statements is important. For example, in the code above, we can’t check that self@end < self@start before we’ve checked that @start and @end are length 1.

When is validation performed?

Objects are validated automatically when constructed and when any property is modified:

x <- range(1, 2:3)
#> Error: <range> object properties are invalid:
#> - @end must be <double>, not <integer>
x <- range(10, 1)
#> Error: <range> object is invalid:
#> - @end (1) must be greater than or equal to @start (10)

x <- range(1, 10)
x@start <- 20
#> Error: <range> object is invalid:
#> - @end (10) must be greater than or equal to @start (20)

You can also manually validate() an object if you use a low-level R function to bypass the usual checks and balances of @:

x <- range(1, 2)
attr(x, "start") <- 3
validate(x)
#> Error: <range> object is invalid:
#> - @end (2) must be greater than or equal to @start (3)

Avoiding validation

Imagine you wanted to write a function that would shift a property to the left or the right:

shift <- function(x, shift) {
  x@start <- x@start + shift
  x@end <- x@end + shift
  x
}
shift(range(1, 10), 1)
#> <range>
#>  @ start: num 2
#>  @ end  : num 11

There’s a problem if shift is larger than @end - @start:

shift(range(1, 10), 10)
#> Error: <range> object is invalid:
#> - @end (10) must be greater than or equal to @start (11)

While the end result of shift() will be valid, an intermediate state is not. The easiest way to resolve this problem is to set the properties all at once:

shift <- function(x, shift) {
  props(x) <- list(
    start = x@start + shift,
    end = x@end + shift
  )
  x
}
shift(range(1, 10), 10)
#> <range>
#>  @ start: num 11
#>  @ end  : num 20

The object is still validated, but it’s only validated once, after all the properties have been modified.

Properties

So far we’ve focused on the simplest form of property specification where you use a named list to supply the desired type for each property. This is a convenient shorthand for a call to new_property(). For example, the property definition of range above is shorthand for:

range <- new_class("range",
  properties = list(
    start = new_property(class_double),
    end = new_property(class_double)
  )
)

Calling new_property() explicitly allows you to control aspects of the property other than its type. The following sections show you how to provide a default value, compute the property value on demand, or provide a fully dynamic property.

Default value

The defaults of new_class() create an class that can be constructed with no arguments:

empty <- new_class("empty", 
  properties = list(
    x = class_double,
    y = class_character,
    z = class_logical
  ))
empty()
#> <empty>
#>  @ x: num(0) 
#>  @ y: chr(0) 
#>  @ z: logi(0)

The default values of the properties will be filled in with “empty” instances. You can instead provide your own defaults by using the default argument:

empty <- new_class("empty", 
  properties = list(
    x = new_property(class_numeric, default = 0),
    y = new_property(class_character, default = ""),
    z = new_property(class_logical, default = NA)
  )
)
empty()
#> <empty>
#>  @ x: num 0
#>  @ y: chr ""
#>  @ z: logi NA

Computed properties

It’s sometimes useful to have a property that is computed on demand. For example, it’d be convenient to pretend that our range has a length, which is just the distance between @start and @end. You can dynamically compute the value of a property by defining a getter:

range <- new_class("range",
  properties = list(
    start = class_double,
    end = class_double,
    length = new_property(
      getter = function(self) self@end - self@start,
    )
  )
)

x <- range(start = 1, end = 10)
x
#> <range>
#>  @ start : num 1
#>  @ end   : num 10
#>  @ length: num 9

Computed properties are read-only:

x@length <- 20
#> Error: Can't set read-only property <range>@length

Dynamic properties

You can make a computed property fully dynamic so that it can be read and written by also supplying a setter. For example, we could extend the previous example to allow the @length to be set, by modifying the @end of the vector:

range <- new_class("range",
  properties = list(
    start = class_double,
    end = class_double,
    length = new_property(
      class = class_double,
      getter = function(self) self@end - self@start,
      setter = function(self, value) {
        self@end <- self@start + value
        self
      }
    )
  )
)

x <- range(start = 1, end = 10)
x
#> <range>
#>  @ start : num 1
#>  @ end   : num 10
#>  @ length: num 9

x@length <- 5
x
#> <range>
#>  @ start : num 1
#>  @ end   : num 6
#>  @ length: num 5

A setter is a function with arguments self and value that returns a modified object.