R6/S3 and S4: getting the best of both worlds

Why would you even want to mix these two paradigms?

I really love the design, simplicity and features of R6 classes. However, I also really like S4‘s method dispatching mechanism a lot: you can dispatch based on not just one, but multiple signature arguments.

Thus, sometimes you just want it all: use R6 instead of S4 classes, but they should also play nicely with/in S4 methods.

What do you need to do?

When you want to use instances of R6 classes as signature arguments of S4 methods, you need to create “formal S4 equivalents” for your R6 classes. Furthermore, you need to make sure that the S3 inheritance structure is correctly represented as well.

Let’s start out with some R6 class definitions:

require("R6")
ClassA <- R6Class("ClassA",
public = list(foo = function() "foo!"))
ClassB <- R6Class("ClassB", inherit = ClassA,
public = list(bar = function() "bar!"))

Currently, those S3 classes have no formal S4 equivalent yet – which means you can’t use them in S4 methods:

isClass("R6")
# [1] FALSE
isClass("ClassA")
# [1] FALSE
isClass("ClassB")
# [1] FALSE

The following lines of code create those S4 equivalents. You can think of it sort of as “S4 dummies”. In fact, from S4’s perspective, these classes are nothing more than virtual classes. You couldn’t instatiate via new() even if you wanted to as, technically speaking, they don’t have anything to do with your informal S3 classes

setOldClass(c("ClassA", "R6"))
setOldClass(c("ClassB", "ClassA"))

Note how we preserve the inheritance structure by using c(): ClassA inherits from R6 (which you can use as a handy “catch-all-R6-instances” baselayer class; you could also drop this) and ClassB inherits from ClassA.

Via those S4 dummies, your classes are now “recognized by the S4 world”:

isClass("R6")
# [1] TRUE
isClass("ClassA")
# [1] TRUE
isClass("ClassB")
# [1] TRUE

getClass("R6")
# Virtual Class "R6" [in ".GlobalEnv"]
#
# Slots:
#
#   Name:   .S3Class
# Class: character
#
# Extends: "oldClass"
#
# Known Subclasses:
#   Class "ClassA", directly
# Class "ClassB", by class "ClassA", distance 2

getClass("ClassA")
# Virtual Class "ClassA" [in ".GlobalEnv"]
#
# Slots:
#
#   Name:   .S3Class
# Class: character
#
# Extends:
#   Class "R6", directly
# Class "oldClass", by class "R6", distance 2
#
# Known Subclasses: "ClassB"

getClass("ClassB")
# Virtual Class "ClassB" [in ".GlobalEnv"]
#
# Slots:
#
#   Name:   .S3Class
# Class: character
#
# Extends:
#   Class "ClassA", directly
# Class "R6", by class "ClassA", distance 2
# Class "oldClass", by class "ClassA", distance 3

Thus, you can now use instances of them as signature arguments in S4 methods:

setGeneric("foo", signature = c("x", "y"),
def = function(x, y) standardGeneric("foo")
)
setMethod("foo", c(x = "R6", y = "ANY"),
definition = function(x, y) {
"I'm the method for `R6` and `ANY`"
})
setMethod("foo", c(x = "R6", y = "character"),
definition = function(x, y) {
"I'm the method for `R6` and `character`"
})
setMethod("foo", c(x = "ClassA", y = "ClassB"),
definition = function(x, y) {
"I'm the method for `ClassA` and `ClassB`"
})

Testing things out:

foo(x = ClassA$new())
# [1] "I'm the method for `R6` and `ANY`"
foo(x = ClassB$new())
# [1] "I'm the method for `R6` and `ANY`"
foo(x = ClassB$new(), y = "hello world!")
# [1] "I'm the method for `R6` and `character`"
foo(x = ClassB$new(), y = ClassB$new())
# [1] "I'm the method for `ClassA` and `ClassB`"

Where is the best place to register the formal equivalents?

IMO, the best place for calls to setOldClass() is inside .onAttach().

At this stage your package’s namespace has been loaded which means that there exists an namespace environment (check as.environment("package:your-packagename")) which you can assign the formal S4 equivalents to. That keeps things nicely organized (when not specifing the location via where in setOldClass() they would end up in ´.GlobalEnv()`)

Here’s an example of how your .onAttach() function might look like. Just put it somewhere inside your package’s R directory.

.onAttach <- function(libname, pkgname) {
where <- as.environment("package:your-packagename")
clss <- list(
c("ClassA", "R6"),
c("ClassB", "ClassA")
)
## Ensure clean initial state for subsequent package loads
## while developing //
sapply(clss, function(cls) {
idx <- sapply(cls, isClass)
suppressWarnings(try(sapply(cls[idx], removeClass,
where = where), silent = TRUE))
})
## Set formal class equivalent //
sapply(clss, function(cls) {
try(setOldClass(cls, where = where), silent = TRUE)
})
}

I’ve made the experience that, for subsequent package loads (while developing), not ensuring the prior removal of your formal S4 equivalents leads to the fact that getClasses(where = where) returns character() instead of the names of the formal classes you defined. While this seems to have no actual negative effect (R still recognizes the classes), I’m feeling more comfortable with R reassuring me that the formal classes in fact do live in my package’s namespace – hence the removeClass() part.

Simplifying things a bit: package r6x

That’s all well and good, but I didn’t like the fact that S3 and S4 class definitions are not bound together. This way, it’s very easy to forget to update your list of classes you need to register with setOldClass() when you change your R6 class definitions.

I thus put together a little package called r6x that simplifies the whole process:

First, define you R6 classes with withFormalClass() instead of R6Class():

Test <- withFormalClass(
R6Class("Test", public = list(foo = function() "hello world!")
)
)
Test2 <- withFormalClass(
R6Class("Test2", inherit = Test,
public = list(bar = function() "hello world!")
)
)

Compared to R6::R6Class(), the function only differs with respect to its side effect: buffering the S3 inheritance information of each class defined by it for later calls to setOldClass().

A call to formalizeClasses() (best inside .onAttach()) picks up the buffered information and takes care of the actual calls to setOldClass().

That way you can rest assured that all of your R6 classes will have their formal S4 equivalents defined in your package’s namespace.

Here’s a minimal version of how your .onAttach() would need to look like:

.onAttach <- function(libname, pkgname) {
r6x::formalizeClasses()
}

Feature request: making :: work for formal classes and in text strings

While the approach presented let’s you use the best of both worlds, one main caveat of S4 remains (AFAIU): not being able to use namespace information when selecting formal classes when defining inheritance structures (e.g. in setClass(), setRefClass() or setOldClass()).

IMO this is inconsitent compared to the way we treat other language components/objects in namespaces. For objects we are used to using ::. Thus I think we should make the :: operator work for formal classes as well somehow.

This also sort of goes back to an old SO post of mine.

The other day, I actually ran into the following situation:

  • I wanted to use the name Module for one of my R6 classes
  • When trying to register its formal S4 counterpart while preserving inheritance, I found out that Rcpp also defines a formal class of this name
  • This led to an error in the call to setOldClass() as I was not able to tell R that it should use Module in my package’s namespace instead of that in Rcpp‘s namespace.

Here’s an actual example:

Running devtools::load_all() results in the formal class Module from namespace Rcpp being loaded (start with a fresh R session):

## Before running `devtools::load_all()`:
isClass("Module")

## After running `devtools::load_all()`:
devtools::load_all()
isClass("Module")

getClass("Module")
# Class "Module" [package "Rcpp"]
#
# Slots:
#
# Name: .xData
# Class: environment
#
# Extends:
# Class ".environment", directly
# Class "environment", by class ".environment", distance 2, with explicit coerce
# Class "refObject", by class ".environment", distance 3, with explicit coerce

This prevents you from preserving/defining the following inheritance structure when registering formal S4 equivalents:

require("R6")
Object <- R6Class("Object",
public = list(foo = function() "foo!")
)
Module <- R6Class("Module", inherit = Object,
public = list(bar = function() "bar!")
)

setOldClass(c("Object", "R6"))
setOldClass(c("Module", "Object"))
# Error in setOldClass(c("Module", "Object")) :
inconsistent old-style class information for “Module”; the class is defined but does not extend “Object” and is not valid as the data part

To be fair: this only seems to happen when you call setOldClass() without providing an explicit value for where and thus could be prevented by the approach above (i.e., setOldClass() is called inside .onAttach() and thus where = as.environment("package:your-packagename")).

But still: wouldn’t being able to use :: be great in such situations in general?! After all, Rcpp‘s and my version of Module do in fact already live in two distinct namespaces:

attributes(getClass("Module"))$package
# [1] "Rcpp"

So the only thing that’s missing is being able to tell R explicitly which namespace it should look into by means of a special text string (PSEUDO CODE):

setRefClass("InheritsfromRcppModule", contains = "Rcpp::Module")
setRefClass("InheritsfromMyModule", contains = "mypackage::Module")

setOldClass(c("SubModule", "Rcpp::Module"))
setOldClass(c("SubModule", "mypackage::Module"))   

After all, the : operator is already being used in text strings:

as.environment(&amp;quot;package:stats&amp;quot;)

So I’m guessing that it should also be possible to treat strings of the structure :: in a similar way in calls to setClass(), setRefClass() or setOldClass().

Any ideas on how to realize that?

Advertisements

2 thoughts on “R6/S3 and S4: getting the best of both worlds

  1. Thanks for the clarifying post and package.
    I tried your .anAttach() function, with S3 classes in my package, that I need to be “recognized” by S4.
    However, when I load the package, I get an error because S4 methods that I wrote for my classes don’t find the S4 virtual class. It seems that the registration of the classes happen after the methods in the package are parsed.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s