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)    7.06µs   8.47µs   112848.        0B     56.5
#> 2 foo_S3(x)    2.48µs   2.87µs   313675.        0B     62.7
#> 3 foo_S4(x)    2.75µs   3.31µs   291519.        0B     29.2

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)  13.29µs   15.4µs    62217.        0B     49.8
#> 2 bar_S4(x, y)   7.62µs   8.73µs   110941.        0B     44.4

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   7.19µs   8.72µs   109867.        0B    66.0 
#>  2 worst                3          15   7.39µs   8.95µs   106412.        0B    53.2 
#>  3 best                 5          15   7.31µs   8.85µs   108265.        0B    65.0 
#>  4 worst                5          15   7.58µs   9.14µs   104278.        0B    62.6 
#>  5 best                10          15   7.36µs   8.86µs   107710.        0B    64.7 
#>  6 worst               10          15   7.76µs   9.31µs   103026.        0B    61.9 
#>  7 best                50          15   7.76µs   9.39µs   101831.        0B    61.1 
#>  8 worst               50          15   9.53µs  11.21µs    85176.        0B    51.1 
#>  9 best               100          15   8.19µs   9.32µs    95651.        0B    19.1 
#> 10 worst              100          15  11.78µs  13.02µs    75673.        0B    15.1 
#> 11 best                 3         100   7.16µs   8.43µs   116221.        0B    11.6 
#> 12 worst                3         100   7.31µs   8.55µs   115089.        0B    23.0 
#> 13 best                 5         100   7.08µs   8.22µs   119284.        0B    23.9 
#> 14 worst                5         100   7.79µs   8.96µs   109673.        0B    11.0 
#> 15 best                10         100   7.25µs   8.39µs   115648.        0B    23.1 
#> 16 worst               10         100   8.12µs   9.24µs   106336.        0B    21.3 
#> 17 best                50         100   7.75µs   9.04µs   107264.        0B    21.5 
#> 18 worst               50         100  12.77µs  14.07µs    65742.        0B     6.57
#> 19 best               100         100   8.29µs   9.49µs   101973.        0B    10.2 
#> 20 worst              100         100  19.35µs  20.57µs    47825.        0B     9.57

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.85µs   9.99µs    97974.        0B    19.6 
#>  2 worst                3          15   9.06µs  10.37µs    94593.        0B    18.9 
#>  3 best                 5          15   8.91µs  10.19µs    96341.        0B    19.3 
#>  4 worst                5          15   9.36µs  10.59µs    92704.        0B    18.5 
#>  5 best                10          15   8.87µs  10.29µs    95119.        0B    19.0 
#>  6 worst               10          15    9.7µs  11.03µs    85266.        0B    17.1 
#>  7 best                50          15   9.89µs  11.21µs    86801.        0B    17.4 
#>  8 worst               50          15  13.19µs   14.5µs    67078.        0B    13.4 
#>  9 best               100          15  10.98µs  12.41µs    77534.        0B    15.5 
#> 10 worst              100          15  17.82µs  19.21µs    51082.        0B    10.2 
#> 11 best                 3         100   9.22µs  10.58µs    89729.        0B    17.9 
#> 12 worst                3         100    9.8µs  11.16µs    86005.        0B    17.2 
#> 13 best                 5         100   9.02µs  10.41µs    92189.        0B    18.4 
#> 14 worst                5         100   9.94µs  11.32µs    85912.        0B    17.2 
#> 15 best                10         100   9.19µs  10.58µs    91900.        0B    18.4 
#> 16 worst               10         100  11.15µs  12.45µs    78488.        0B    15.7 
#> 17 best                50         100  10.36µs  11.73µs    82756.        0B    16.6 
#> 18 worst               50         100  20.32µs   21.7µs    45176.        0B     9.04
#> 19 best               100         100  11.28µs  12.78µs    75319.        0B    15.1 
#> 20 worst              100         100   31.2µs  32.65µs    30157.        0B     9.05