Performance

library(S7)

The dispatch performance should be roughly on par with S3 and S4, though as this is implemented in a package there is some overhead due to .Call vs .Primitive.

text <- new_class("text", parent = class_character)
number <- new_class("number", parent = class_double)

x <- text("hi")
y <- number(1)

foo_S7 <- new_generic("foo_S7", "x")
method(foo_S7, text) <- function(x, ...) paste0(x, "-foo")

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

foo_S3.text <- function(x, ...) {
  paste0(x, "-foo")
}

library(methods)
setOldClass(c("number", "numeric", "S7_object"))
setOldClass(c("text", "character", "S7_object"))

setGeneric("foo_S4", function(x, ...) standardGeneric("foo_S4"))
#> [1] "foo_S4"
setMethod("foo_S4", c("text"), function(x, ...) paste0(x, "-foo"))

# Measure performance of single dispatch
bench::mark(foo_S7(x), foo_S3(x), foo_S4(x))
#> # A tibble: 3 × 6
#>   expression      min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 foo_S7(x)    4.55µs      5µs   192830.        0B     57.9
#> 2 foo_S3(x)    1.23µs   1.35µs   661710.        0B     66.2
#> 3 foo_S4(x)    1.35µs   1.52µs   620031.        0B      0

bar_S7 <- new_generic("bar_S7", c("x", "y"))
method(bar_S7, list(text, number)) <- function(x, y, ...) paste0(x, "-", y, "-bar")

setGeneric("bar_S4", function(x, y, ...) standardGeneric("bar_S4"))
#> [1] "bar_S4"
setMethod("bar_S4", c("text", "number"), function(x, y, ...) paste0(x, "-", y, "-bar"))

# Measure performance of double dispatch
bench::mark(bar_S7(x, y), bar_S4(x, y))
#> # A tibble: 2 × 6
#>   expression        min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr>   <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 bar_S7(x, y)   7.95µs   8.61µs   111847.        0B     56.0
#> 2 bar_S4(x, y)   3.77µs    4.1µs   235555.        0B     23.6

A potential optimization is caching based on the class names, but lookup should be fast without this.

The following benchmark generates a class hierarchy of different levels and lengths of class names and compares the time to dispatch on the first class in the hierarchy vs the time to dispatch on the last class.

We find that even in very extreme cases (e.g. 100 deep hierarchy 100 of character class names) the overhead is reasonable, and for more reasonable cases (e.g. 10 deep hierarchy of 15 character class names) the overhead is basically negligible.

library(S7)

gen_character <- function (n, min = 5, max = 25, values = c(letters, LETTERS, 0:9)) {
  lengths <- sample(min:max, replace = TRUE, size = n)
  values <- sample(values, sum(lengths), replace = TRUE)
  starts <- c(1, cumsum(lengths)[-n] + 1)
  ends <- cumsum(lengths)
  mapply(function(start, end) paste0(values[start:end], collapse=""), starts, ends)
}

bench::press(
  num_classes = c(3, 5, 10, 50, 100),
  class_nchar = c(15, 100),
  {
    # Construct a class hierarchy with that number of classes
    text <- new_class("text", parent = class_character)
    parent <- text
    classes <- gen_character(num_classes, min = class_nchar, max = class_nchar)
    env <- new.env()
    for (x in classes) {
      assign(x, new_class(x, parent = parent), env)
      parent <- get(x, env)
    }

    # Get the last defined class
    cls <- parent

    # Construct an object of that class
    x <- do.call(cls, list("hi"))

    # Define a generic and a method for the last class (best case scenario)
    foo_S7 <- new_generic("foo_S7", "x")
    method(foo_S7, cls) <- function(x, ...) paste0(x, "-foo")

    # Define a generic and a method for the first class (worst case scenario)
    foo2_S7 <- new_generic("foo2_S7", "x")
    method(foo2_S7, S7_object) <- function(x, ...) paste0(x, "-foo")

    bench::mark(
      best = foo_S7(x),
      worst = foo2_S7(x)
    )
  }
)
#> # A tibble: 20 × 8
#>    expression num_classes class_nchar      min   median `itr/sec` mem_alloc `gc/sec`
#>    <bch:expr>       <dbl>       <dbl> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#>  1 best                 3          15   4.55µs      5µs   195762.        0B     58.7
#>  2 worst                3          15   4.67µs   5.12µs   191035.        0B     76.4
#>  3 best                 5          15   4.55µs      5µs   194971.        0B     58.5
#>  4 worst                5          15   4.71µs   5.21µs   187491.        0B     75.0
#>  5 best                10          15   4.63µs   5.12µs   188134.        0B     75.3
#>  6 worst               10          15   4.96µs   5.41µs   180300.        0B     54.1
#>  7 best                50          15   4.88µs   5.37µs   181461.        0B     72.6
#>  8 worst               50          15   6.36µs   6.85µs   142644.        0B     42.8
#>  9 best               100          15   5.25µs   5.74µs   170367.        0B     68.2
#> 10 worst              100          15   8.12µs   8.69µs   112610.        0B     33.8
#> 11 best                 3         100   4.51µs   5.04µs   192639.        0B     77.1
#> 12 worst                3         100    4.8µs   5.29µs   182849.        0B     73.2
#> 13 best                 5         100   4.63µs   5.12µs   188750.        0B     75.5
#> 14 worst                5         100   4.92µs   5.54µs   176693.        0B     53.0
#> 15 best                10         100   4.76µs   5.25µs   184134.        0B     55.3
#> 16 worst               10         100   5.95µs   6.44µs   151700.        0B     60.7
#> 17 best                50         100   4.84µs   5.37µs   180968.        0B     72.4
#> 18 worst               50         100  10.95µs  11.44µs    86264.        0B     34.5
#> 19 best               100         100   5.25µs   5.54µs   178132.        0B     71.3
#> 20 worst              100         100  16.65µs  17.14µs    57669.        0B     23.1

And the same benchmark using double-dispatch

bench::press(
  num_classes = c(3, 5, 10, 50, 100),
  class_nchar = c(15, 100),
  {
    # Construct a class hierarchy with that number of classes
    text <- new_class("text", parent = class_character)
    parent <- text
    classes <- gen_character(num_classes, min = class_nchar, max = class_nchar)
    env <- new.env()
    for (x in classes) {
      assign(x, new_class(x, parent = parent), env)
      parent <- get(x, env)
    }

    # Get the last defined class
    cls <- parent

    # Construct an object of that class
    x <- do.call(cls, list("hi"))
    y <- do.call(cls, list("ho"))

    # Define a generic and a method for the last class (best case scenario)
    foo_S7 <- new_generic("foo_S7", c("x", "y"))
    method(foo_S7, list(cls, cls)) <- function(x, y, ...) paste0(x, y, "-foo")

    # Define a generic and a method for the first class (worst case scenario)
    foo2_S7 <- new_generic("foo2_S7", c("x", "y"))
    method(foo2_S7, list(S7_object, S7_object)) <- function(x, y, ...) paste0(x, y, "-foo")

    bench::mark(
      best = foo_S7(x, y),
      worst = foo2_S7(x, y)
    )
  }
)
#> # A tibble: 20 × 8
#>    expression num_classes class_nchar      min   median `itr/sec` mem_alloc `gc/sec`
#>    <bch:expr>       <dbl>       <dbl> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#>  1 best                 3          15   5.41µs   5.82µs   165292.        0B     82.7
#>  2 worst                3          15    5.7µs   6.15µs   157902.        0B     63.2
#>  3 best                 5          15   5.41µs    5.9µs   165632.        0B     66.3
#>  4 worst                5          15   5.78µs   6.27µs   155876.        0B     62.4
#>  5 best                10          15   5.54µs   5.99µs   162462.        0B     65.0
#>  6 worst               10          15   6.03µs   6.52µs   150108.        0B     60.1
#>  7 best                50          15   6.07µs   6.56µs   148440.        0B     59.4
#>  8 worst               50          15   8.57µs   9.14µs   106982.        0B     53.5
#>  9 best               100          15   6.81µs    7.3µs   133233.        0B     66.6
#> 10 worst              100          15  12.14µs  12.75µs    76864.        0B     38.5
#> 11 best                 3         100   5.82µs    6.4µs   150848.        0B     75.5
#> 12 worst                3         100   6.52µs   7.09µs   137028.        0B     54.8
#> 13 best                 5         100   5.82µs   6.36µs   152363.        0B     61.0
#> 14 worst                5         100   7.22µs   7.75µs   125326.        0B     50.2
#> 15 best                10         100   5.78µs   6.31µs   152658.        0B     61.1
#> 16 worst               10         100   8.65µs   9.31µs   104963.        0B     42.0
#> 17 best                50         100   6.07µs    6.6µs   146713.        0B     73.4
#> 18 worst               50         100  17.34µs  18.12µs    54393.        0B     27.2
#> 19 best               100         100   6.89µs   7.22µs   136725.        0B     68.4
#> 20 worst              100         100  30.01µs  30.59µs    32264.        0B     16.1