15 S4

15.1 Introduction

Like S3, S4 implements functional OOP, but is much more rigorous and strict. There are three main differences between S3 and S4:

  • S4 classes have formal definitions provided by a call to setClass(). An S4 class can have multiple parents (multiple inheritance).

  • The fields of an S4 object are not attributes or named elements, but instead are called slots and are accessed with the special @ operator.

  • Methods are not defined with a naming convention, but are instead defined by a call to setMethod(). S4 generics can dispatch on multiple arguments (multiple dispatch).

A good overview of the motivation of S4 and its historical context can be found in Chambers and others (2014), https://projecteuclid.org/download/pdfview_1/euclid.ss/1408368569.

S4 is a rich system, and it’s not possible to cover all of it in one chapter. Instead, we’ll focus on what you need to know to read most S4 code, and write basic S4 components. Unfortunately there is not one good reference for S4 and as you move towards more advanced usage, you will need to piece together needed information by carefully reading the documentation and performing experiments. Some good places to start are:

Outline

Prerequisites

All S4 related functions live in the methods package. This package is always available when you’re running R interactively, but may not be available when running R in batch mode (i.e. from Rscript). For this reason, it’s a good idea to call library(methods) whenever you use S4. This also signals to the reader that you’ll be using the S4 object system.

15.2 Classes

Unlike S3, S4 classes have a formal definition. To define an S4 class, you must define three key properties:

  • The class name. By convention, S4 class names use UpperCamelCase.

  • A named character vector that describes the names and classes of the slots (fields). For example, a person might be represented by a character name and a numeric age: c(name = "character", age = "numeric"). The pseudo-class “ANY” allows a slot to accept objects of any type.

  • The name of a class (or classes) to inherit behaviour from, or in S4 terminology, the classes that it contains.

Slots and contains can specify the names of S4 classes, S3 classes (if registered), and base types. We’ll go into more detail about non-S4 classes at the end of the chapter, in S4 and existing code.

To create a class, you call setClass(), supplying these three properties. Lets make this concrete with an example. Here we create two classes: a person with character name and numeric age, and an Employee that inherits slots and methods from Person, adding an additional boss slot that must be a Person. setClass() returns a low-level constructor function, which should be given the class name with a . prefix.

setClass() has 10 other arguments, but they are all either deprecated or not recommended. If you have existing S4 code that uses them, I’d recommend carefully reading the documentation and upgrading to modern practice.

We can now use the constructor to create an object from that class:

It’s also possible to create an instance using new() and the name of the class. This is not recommended because it introduces some ambiguity. What happens if there are two packages that both define the Person class?

In most programming languages, class definition occurs at compile-time, and object construction occurs later, at run-time. In R, however, both definition and construction occur at run time. When you call setClass(), you are registering a class definition in a (hidden) global variable. As with all state-modifying functions you need to use setClass() with care. It’s possible to create invalid objects if you redefine a class after already having instantiated an object:

This isn’t usually a problem, because you’ll define a class once, then leave the definition alone. If you want to enforce a single class definition, you can “seal” it:

15.2.1 Slots

You can access the slots with @ or slot(): @ is equivalent to $, and slot() to [[.

You can list all available slots with slotNames():

Slots should be considered an internal implementation detail. That means:

  • As a user, you should not reach into someone else’s object with @, but instead, look for a method that provides the information you want.

  • As a developer, you should make sure that all public facing slots have their own accessor methods.

We’ll come back how to implement accessors in [Accessors], once you’ve learned how S4 generics and methods work.

15.2.2 Helper

The result of setClass() is a low-level constructor, which means that don’t need to write one yourself. However, this default constructor has three drawbacks:

Like with S3, we resolve these issues by writing a helper function.

This provides the behaviour that we want:

It is possible to achieve the same effect by implementing an initialize() method, but the initialize() generic has a complicated contract and it is very hard to get all the details right.

To re-use checking code in a subclass, you can take advantage of a detail of the constructor: an unnamed argument is interpreted as predefined object from the parent class. For example, to define a constructor for the Employee class that reuses the Person helper, you first create a Person(), then pass that to the .Employee constructor.

As with S3, if the validity checking code is lengthy or expensive, you should pull it out into a separate function which the helper calls.

15.2.3 Introspection

To determine what classes an object inherits from, use is():

To test if an object inherits from a specific class, use the second argument of is():

If you are using a class provided by a package you can get help on it with class?Person.

15.2.4 Exercises

  1. What happens if you define a new S4 class that doesn’t “contain” an existing class? (Hint: read about virtual classes in ?setClass.)

  2. Imagine you were going to reimplement ordered factors, dates, and data frames in S4. Sketch out the setClass() calls that you would use to define the classes. What should they inherit from? What slots should they use?

15.3 Generics and methods

The job of a generic is to perform method dispatch, i.e. find the method designed to handle the combination of classes passed to the generic. Here you’ll learn how to define S4 generics and methods, then in the next section we’ll explore precisely how S4 method dispatch works.

S4 generics have a similar structure to S3 generics, but are a little more formal. To create an new S4 generic, you call setGeneric() with a function that calls standardGeneric(). .

Note that it is bad practice to use { in the generic function. This triggers a special case that is more expensive, and generally best avoided.

Like setClass(), setGeneric() has many other arguments. There is only one that you need to know about: signature. This allows you to control the arguments that are used for method dispatch. If signature is not supplied, all arguments (apart from ...) are used. It is occassionally useful to remove arguments from dispatch. This allows you to require that methods provide arguments like verbose = TRUE or quiet = FALSE, but they don’t take part in dispatch.

A generic isn’t useful without some methods, and in S4 you add methods with setMethod(). There are three important arguments: the name of the generic, the name of the class, and the method itself.

(Again, just like setClass(), setMethod() has other arguments, but you should never use them.)

15.3.1 Show method

As with S3, the most commonly defined S4 method controls printing, but in S4 we use a different generic: show().

When defining a method for an existing generic, you need to first determine the arguments. You can get those from the documentation or by looking at the formals of the generic:

Our show method needs to have a single argument object:

More formally, the second argument to setMethod() is called the signature. In S4, unlike S3, the signature can include multiple arguments. This makes method dispatch in S4 substantially more complicated, but avoids having to implement double-dispatch as a special case. We’ll talk more about multiple dispatch in the next section.

15.3.2 Accessor methods

Slots are generally considered to be an internal implementation detail: they can change without warning and user code should avoid accessing them directly. Instead, all user-readble slots should get an accessor. If the slot is unique to the class, this can just be a function:

But typically, you will want to define a generic and provide a method for your class:

If the slot is also writeable, you should provide a setter function. Typically this function will be more complicated than the getter because you’ll need to check that the new value is valid, or you may need to modify other slots. Here we make sure that this functions only allows changing the values, not the length:

Again, you’ll typically want to do this with a method:

15.3.3 Coercion methods

To coerce S4 object from one class to another, use as(). One nice feature of S4 is that it provides default coercion methods for you:

The defaults are not always quite right. For example, what happens if we try to coerce a Person to an Employee? The coercion succeeds because the boss slot is “helpfully” filled in with a default object:

We can override the default coercion to supply an informative error.

15.3.4 Introspection

To list all the methods that belong to a generic, or that are associated with a class, use sloop::s4_methods_generic() and s4_methods_class():

If you’re looking for the implementation of a specific method, you can use selectMethod(). You give it the name of the generic and the class (or classes) that it’s called with:

If you’re using a method defined in a package, the easiest way to get help on it is to construct a valid call, and then put ? in front it. ? will use the arguments to figure out which help file you need:

15.3.5 Exercises

  1. In the definition of the generic, why is it necessary to repeat the name of the generic twice?

  2. What’s the difference between the generics generated by these two calls?

  3. What happens if you define a method with different argument names to the generic?

  4. What other ways can you find help for a method? Read ?"?" and summarise the details.

15.4 Method dispatch

S4 dispatch is complicated because S4 has two important features:

  • Multiple inheritance, i.e. a class can have multiple parents,
  • Multiple dispatch, i.e. a generic can use multiple arguments to pick a method.

These features make S4 very powerful, but can also make it hard to understand which method will get selected for a given combination of inputs.

To explain method dispatch, we’ll start simple with single inheritance and single dispatch, and work our way up to the more complicated cases. To illustrate the ideas without getting bogged down in the details, we’ll use an imaginary class graph based on emoji:

Emoji give us very compact class names (just one symbol) that evoke the relationships between the classes. It should be straightforward to remember that 😜 inherits from 😉 which inherits from 😶, and that 😎 inherits from both 🕶 and 🙂

15.4.1 Single dispatch

Let’s start with the simplest case: a generic function that dispatches on a single class with a single parent. The method dispatch here is quite simple, and the same as S3, but this will serve to define the graphical conventions we’ll use for the more complex cases.

There are two parts to this diagram:

  • The top part, f(...), defines the scope of the diagram. Here we have a generic with one argument, and we’re going to explore method dispatch for a class hierarchy that is three levels deep. We’ll only ever look at a small fragment of the complete class graph. This keeps individual diagrams simple while helping you build intuition that you apply to more complex class graphs.

  • The bottom part is the method graph and displays all the possible methods that could be defined. Methods that have been defined (i.e. with setMethod()) have a grey background.

To find the method that gets called, you start with the class of the actual arguments, then follow the arrows until you find a method that exists. For example, if you called the function with an object of class 😉 you would follow the arrow right to find the method defined for the more general 😶 class. If no method is found, method dispatch has failed and you get an error. For this reason, class graphs should usually have methods defined for all the terminal nodes, i.e. those on the far right.

There are two pseudo-classes that you can define methods for. These are called pseudo-classes because they don’t actually exist, but allow you to define useful behaviours. The first pseudo-class is “ANY”. This matches any class, and plays the same role as the default pseudo-class in S3. For technical reasons that we’ll get to later, the link to the “ANY” method is longer than the links between the other classes:

The second pseudo-class is “MISSING”. If you define a method for this “class”, it will match whenever the argument is missing. It’s generally not useful for functions that take a single argument, but can be used for functions like + and - that behave differently depending on whether they have one or two arguments.

15.4.2 Multiple inheritance

Things get more complicated when the class has multiple parents.

The basic process remains the same: you start from the actual class supplied to the generic, then follow the arrows until you find a defined method. The wrinkle is now that there are multiple arrows to follow, so you might find multiple methods. If that happens, you pick the method that is closest, i.e. requires travelling the fewest arrows.

(The method graph is a powerful metaphor that helps you understand how method dispatch works. However, implementing method dispatch in this way would be rather inefficient so the actual approach that S4 uses is somewhat different. You can read the details in ?Methods_Details)

What happens if methods are the same distance? For example, imagine we’ve defined methods for 🕶 and 🙂, and we call the generic with 😎. Note that there’s no implementation for the 😶 class, as indicated by the red double outline.

This is called an ambiguous method, and in diagrams I’ll illustrate it with a thick dotted border. When this happens in R, you’ll get a warning, and one of the two methods is basically picked at random (it uses the method that comes first in the alphabet). When you discover ambiguity you should always resolve it by providing a more precise method:

The fallback “ANY” method still exists but the rules are little more complex. As indicated by the wavy dotted lines, the “ANY” method is always considered further away than a method for a real class. This means that it will never contribute to ambiguity.

It is hard to simultaneously prevent ambiguity, ensure that every terminal method has an implementation, and minimise the number of defined methods (in order to benefit from OOP). For example, of the six ways to define only two methods for this call, only one is free from problems. For this reason, I recommend using multiple inheritance with extreme care: you will need to carefully think about the method graph and plan accordingly.

15.4.3 Multiple dispatch

Once you understand multiple inheritance, understanding multiple dispatch is straightforward. You follow multiple arrows in the same way as previously, but now each method is specified by two classes (separated by a comma).

I’m not going to show examples of dispatching on more than two arguments, but you can follow the basic principles to generate your own method graphs.

The main difference between multiple inheritance and multiple dispatch is that there are many more arrows to follow. The following diagram shows four defined methods which produce two ambiguous cases:

Multiple dispatch tends to be less tricky to work with than multiple inheritance because there are usually fewer terminal class combinations. In this example, there’s only one. That means, at a minimum, you can define a single method and have default behaviour for all inputs.

15.4.4 Multiple dispatch and multiple inheritance

Of course you can combine multiple dispatch with multiple inheritance:

A still more complicated case dispatches on two classes, both of which have multiple inheritance:

However, as the method graph gets more and more complicated it gets harder and harder to predict which actual method will get called given a combination of inputs, and it gets harder and harder to make sure that you haven’t introduced ambiguity. I highly recommend avoiding the combination of the two. There are some techniques (like mixins) that allow you to tame this complexity, but I am not aware of a detailed treatment as applied to S4.

15.4.5 Exercises

  1. Take the last example which shows multiple dispatch over two classes that use multiple inheritance. What happens if you define a method for all terminal classes? Why does method dispatch not save us much work here?

15.5 S4 and existing code

Even when writing new S4 code, you’ll still need to interact with existing S3 classes and functions, including existing S3 generics. This section describes how S4 classes, methods, and generics interact with existing code.

15.5.1 Classes

In slots and contains you can use S4 classes, S3 classes, or the implicit class of a base type. To use an S3 class, you must first register it with setOldClass(). You call this function once for each S3 class, giving it the class attribute. For example, the following definitions are already provided by base R:

Generally, these definitions should be provided by the creator of the S3 class. If you’re trying to build an S4 class on top of a S3 class provided by a package, it is better to request that the package maintainer add this call to the package, rather than running it yourself.

If an S4 object inherits from an S3 class or a base type, it will have a special virtual slot called .Data. This contains the underlying base type or S3 object:

It is possible to define S3 methods for S4 generics, and S4 methods for S3 generics (provided you’ve called setOldClass()). However, it’s more complicated than it might appear at first glance, so make sure you thoroughly read ?Methods_for_S3.

15.5.2 Generics

As well as creating a new generic from scratch (as shown in generics and methods), it’s also possible to convert an existing function to a generic.

In this case, the existing function becomes the default (“ANY”) method:

Note that setMethod() will automatically call setGeneric() if the first argument isn’t already a generic, enabling you to turn any existing function into an S4 generic. I think it is ok to convert an existing S3 generic to S4, but you should avoid converting regular functions because it makes code harder to use (and requires coordination if done by multiple packages).

15.5.3 Exercises

References

Chambers, John M, and others. 2014. “Object-Oriented Programming, Functional Programming and R.” Statistical Science 29 (2). Institute of Mathematical Statistics:167–80.