S7

Learning objectives

  • Recognize the challenges with S3 and S4 that motivated development of S7
  • Create S7 classes
  • Define custom validators for S7 classes and properties
  • Create S7 generics and methods
  • Use S7 with S3 and/or S4
library(S7)

Motivation for S7

S7 resolves challenges with S3

  • S3 has no formal class definitions
  • S3 methods are difficult to find
  • S3 properties are in attributes, but attr() does fuzzy matching
  • S3 method dispatch via UseMethod() is fuzzy

“Now for some obscure details that need to appear somewhere” —?UseMethod

  • NextMethod() depends on what’s loaded
  • Conversion between S3 classes is fuzzy (vs S7::convert() generic)

S7 resolves challenges with S4

  • S4’s multiple inheritance causes more problems than it solves
  • S4’s method dispatch is smart but hard to predict (S7 is explicit)
  • S4 is clean break from S3, made it hard to switch from S3 to S4
  • S4 pretends users can’t use @ to access slots, but they do it anyway

S7 extends S3 and replaces S4

  • Name comes from S3 + S4 = S7, but…
    • S7 objects are S3 objects
    • S7 objects are not S4 objects
    • But there’s some overlap (see last section)

Classes and objects (and properties)

Define S7 classes with S7::new_class()

class_person <- new_class(
  "Person",
  properties = list(name = class_character, age = class_numeric)
)
me <- class_person(name = "Jon", age = 50)
me
#> <Person>
#>  @ name: chr "Jon"
#>  @ age : num 50
S7_inherits(me, class_person)
#> [1] TRUE
class(me)
#> [1] "Person"    "S7_object"
inherits(me, "Person")
#> [1] TRUE

Access S7 object properties with @

me@name
#> [1] "Jon"
me@age
#> [1] 50

S7 objects are validated during construction and on property assignment

me@age <- "fifty"
#> Error: <Person>@age must be <integer> or <double>, not <character>
us <- class_person(name = 1:2, age = c("fifty", "forty-nine"))
#> Error: <Person> object properties are invalid:
#> - @name must be <character>, not <integer>
#> - @age must be <integer> or <double>, not <character>

validator argument customizes validation

class_person <- new_class(
  "Person",
  properties = list(name = class_character, age = class_numeric),
  validator = function(self) {
    if (length(self@name) != length(self@age)) {
      "@name and @age must be the same length"
    }
  }
)
us <- class_person(name = c("Jon", "Leyla"), age = c(50, -5, 49))
#> Error: <Person> object is invalid:
#> - @name and @age must be the same length
me <- class_person("Jon", 50)
me@age <- 50:60
#> Error: <Person> object is invalid:
#> - @name and @age must be the same length

Set properties all at once to avoid intermediate invalid states

us <- me
us@name <- c("Jon", "Leyla")
#> Error: <Person> object is invalid:
#> - @name and @age must be the same length
# Quick S3 method to demonstrate
c.Person <- function(x, y) {
  props(x) <- list(name = c(x@name, y@name), age = c(x@age, y@age))
  x
}
us <- c(me, class_person("Leyla", 49))
us
#> <Person>
#>  @ name: chr [1:2] "Jon" "Leyla"
#>  @ age : num [1:2] 50 49

S7::new_property() defines custom property types

prop_positive <- new_property(
  class = class_numeric,
  validator = function(value) {
    if (any(value <= 0)) "must be positive"
  }
)
class_person <- new_class(
  "Person",
  properties = list(name = class_character, age = prop_positive),
  validator = function(self) {
    if (length(self@name) != length(self@age)) {
      "@name and @age must be the same length"
    }
  }
)
us <- class_person(name = c("Jon", "Leyla"), age = c(50, -5))
#> Error: <Person> object properties are invalid:
#> - @age must be positive

Properties can be computed

class_circle <- new_class(
  "Circle",
  properties = list(
    radius = class_numeric,
    area = new_property(
      class = class_numeric,
      getter = function(self) {
        pi * self@radius^2
      }
    )
  )
)
c1 <- class_circle(radius = 1)
c1@area == pi
#> [1] TRUE
c1@radius <- 2
c1@area == 4*pi
#> [1] TRUE

Properties can be fully dynamic

class_circle2 <- new_class(
  "Circle",
  properties = list(
    radius = class_numeric,
    area = new_property(
      class = class_numeric,
      getter = function(self) pi * self@radius^2,
      setter = function(self, value) {
        if (!length(value)) return(self)
        self@radius <- sqrt(value / pi)
        self
      }
    )
  )
)
c2 <- class_circle2(radius = 1)
c2@area
#> [1] 3.141593
c2@area <- 4*pi
c2@radius
#> [1] 2
c2@radius <- 3
c2@area == 9*pi
#> [1] TRUE

constructor argument customizes object creation

  • (it might be nice to have a slide on this but we don’t yet)

Generics and methods

Define S7 generics & methods with S7::new_generic() and S7::new_method()

are_old <- new_generic("are_old", "x")
method(are_old, class_person) <- function(x) {
  x@age >= 40 & x@name != "Leyla"
}
us
#> <Person>
#>  @ name: chr [1:2] "Jon" "Leyla"
#>  @ age : num [1:2] 50 49
are_old(us)
#> [1]  TRUE FALSE

S7 method dispatch is similar to S3

class_geek <- new_class(
  "Geek",
  parent = class_person
)
me <- class_geek("Jon", 50)
are_old(me) # Dispatches on "Person"
#> [1] TRUE

S7 method chaining is explicit

method(are_old, class_geek) <- function(x) {
  cat("Checking if geeks are old...\n")
  are_old(super(x, class_person)) # Must specify which parent class to use
}
are_old(me)
#> Checking if geeks are old...
#> [1] TRUE

S7 generics allow for multiple dispatch

combine <- new_generic("combine", c("x", "y"))
method(combine, list(class_person, class_person)) <- function(x, y) {
  class_person(
    name = c(x@name, y@name),
    age = c(x@age, y@age)
  )
}
hw <- class_geek("Hadley", 46)
combine(us, hw)
#> <Person>
#>  @ name: chr [1:3] "Jon" "Leyla" "Hadley"
#>  @ age : num [1:3] 50 49 46
combine(me, hw)
#> <Person>
#>  @ name: chr [1:2] "Jon" "Hadley"
#>  @ age : num [1:2] 50 46
method(combine, list(class_geek, class_geek)) <- function(x, y) {
  combined_person <- combine(
    super(x, class_person),
    super(y, class_person)
  )
  class_geek(
    name = combined_person@name,
    age = combined_person@age
  )
}
combine(me, hw)
#> <Geek>
#>  @ name: chr [1:2] "Jon" "Hadley"
#>  @ age : num [1:2] 50 46
  • class_any matches any class
  • class_missing for missing arguments

Compatibility

Compatibility with S3

  • class() for S3 classes, S7_class() for S7 class constructor
  • S7 properties are attributes (so old code that expects those will work)
  • S7 can register methods for:
    • S7 class + S3 generic
    • S3 class + S7 generic
  • S7 classes can inherit from S3 classes
  • S3 classes can inherit from S7 classes

Compatibility with S4

  • S7 classes cannot inherit from S4 classes
  • S4 classes can inherit from S7 classes
  • S7 can register methods for:
    • S7 class + S4 generic
    • S4 class + S7 generic
  • Out of scope: Both support class unions