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)    6.73µs    7.6µs   128049.        0B     64.1
#> 2 foo_S3(x)    2.39µs    2.6µs   376101.        0B     75.2
#> 3 foo_S4(x)     2.6µs    2.9µs   330472.        0B     33.1

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)   12.6µs  13.76µs    70476.        0B     56.4
#> 2 bar_S4(x, y)    6.9µs   7.96µs   122265.        0B     48.9

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   6.64µs   7.54µs   128562.        0B    77.2 
#>  2 worst                3          15   6.85µs   7.51µs   129764.        0B    77.9 
#>  3 best                 5          15   6.68µs   7.49µs   129044.        0B    77.5 
#>  4 worst                5          15   7.01µs   8.02µs   118090.        0B    70.9 
#>  5 best                10          15   6.78µs   7.71µs   124781.        0B    74.9 
#>  6 worst               10          15   7.05µs   7.68µs   126366.        0B    75.9 
#>  7 best                50          15   7.18µs   8.02µs   121537.        0B    73.0 
#>  8 worst               50          15   9.04µs   9.83µs    99267.        0B    59.6 
#>  9 best               100          15   7.76µs   8.74µs    99793.        0B    20.0 
#> 10 worst              100          15  11.54µs  12.59µs    78212.        0B     7.82
#> 11 best                 3         100   6.76µs   7.77µs   126499.        0B    25.3 
#> 12 worst                3         100   7.07µs   8.12µs   120882.        0B    24.2 
#> 13 best                 5         100   6.89µs   7.96µs   123739.        0B    24.8 
#> 14 worst                5         100   7.22µs   8.22µs   119686.        0B    12.0 
#> 15 best                10         100   6.78µs    7.7µs   127298.        0B    25.5 
#> 16 worst               10         100   7.91µs    8.8µs   112005.        0B    22.4 
#> 17 best                50         100   7.23µs   8.28µs   118939.        0B    23.8 
#> 18 worst               50         100  12.24µs  13.18µs    74903.        0B     7.49
#> 19 best               100         100   7.88µs   8.85µs   111326.        0B    22.3 
#> 20 worst              100         100  16.71µs  17.64µs    56046.        0B     5.61

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    8.3µs   9.32µs   105195.        0B    21.0 
#>  2 worst                3          15   8.49µs   9.58µs   102884.        0B    20.6 
#>  3 best                 5          15   8.37µs   9.39µs   104642.        0B    20.9 
#>  4 worst                5          15   8.82µs   9.87µs    99797.        0B    20.0 
#>  5 best                10          15   8.62µs   9.61µs   101945.        0B    20.4 
#>  6 worst               10          15   9.22µs  10.31µs    94481.        0B    28.4 
#>  7 best                50          15   9.37µs  10.37µs    94463.        0B    18.9 
#>  8 worst               50          15  12.77µs  13.78µs    71454.        0B    14.3 
#>  9 best               100          15  10.51µs   11.6µs    84784.        0B    17.0 
#> 10 worst              100          15   17.2µs  18.36µs    53494.        0B    16.1 
#> 11 best                 3         100   8.38µs   9.47µs   103605.        0B    20.7 
#> 12 worst                3         100   8.74µs   9.82µs    99707.        0B    19.9 
#> 13 best                 5         100   8.45µs   9.63µs   100951.        0B    20.2 
#> 14 worst                5         100   9.22µs  10.29µs    94604.        0B    18.9 
#> 15 best                10         100   8.75µs   9.89µs    98615.        0B    19.7 
#> 16 worst               10         100  10.76µs  11.87µs    82389.        0B    24.7 
#> 17 best                50         100   9.64µs  10.72µs    91306.        0B    18.3 
#> 18 worst               50         100  18.94µs  20.24µs    48722.        0B     9.75
#> 19 best               100         100  10.55µs  11.69µs    83550.        0B    16.7 
#> 20 worst              100         100  30.92µs  32.17µs    30586.        0B     9.18