Ga naar de inhoud

S7: A New Object-Oriented Programming System in R

Object-oriented programming in R with S7

R isn’t just about functional programming—it also supports object-oriented programming through various class systems. One the latest object-oriented programming systems is S7, a successor of the more well-known S3 and S4 systems. S7 tries to take the best of S3 and S4, and eliminate some of the downsides of both systems. But what is object-oriented programming to begin with? What is the difference between S7 and S3, S4 and R6? And how do you use it? You will find out all about this in this article about object-oriented programming in R with S7!

Table of Contents

Object-Oriented Programming: the difference with functions

In functional programming, functions are the main building blocks of your code. You write functions to process data, and these functions can be passed around, reused, and combined to perform tasks. This approach focuses on breaking problems into smaller, reusable pieces. And this should sound familiar to you!

R is primarily considered a functional programming language because it emphasizes the use of functions as the main tools for organizing and executing code. In R, functions are first-class citizens, meaning they can be assigned to variables, passed as arguments, and returned from other functions. Most tasks in R, from basic calculations to complex data processing, are carried out through functions. Functional programming is particularly well-suited for statistics and analysis due to the emphasis on transforming data through a series of steps, aka functions. I mean, we all know the dplyr and friends from the tidyverse right?! Step by step you work your way through transformations with all kinds of functions.

Object-oriented programming (OOP), on the other hand, is about bundling data and the actions, aka methods, that operate on that data into objects. These objects are like blueprints, or “classes,” that define both the data structure and the behavior. This approach helps keep code modular and organized, especially for more complex projects, as it allows you to group related things together.

OOP is widely used in several programming languages, like Java, C++, and Python. JavaScript also employs OOP concepts, particularly through prototypes and ES6 classes, to handle various tasks. If you’re developing Shiny applications and are using JavaScript to enhance them, you are probably familiar with OOP already.

S3, S4, R6 and S7

In R, there are several systems for OOP, each offering different ways to structure and manage data and functions. Here’s a brief summary of the main ones:

  1. S3: the simplest and most flexible system. S3 allows you to define objects and methods by attaching class attributes to objects and creating functions that work with those attributes. It’s the most commonly used system, and it is part of base R.

  2. S4: a more formal and rigorous system than S3, and also part of base R. S4 provides strict definitions for classes and methods, making it more formal.  This system is useful when you need more control and validation in your object definitions. It is more complex to use, but you get better guarantees. You will often see this system in Bioconductor packages.

  3. R6: based on S3, R6 offers a class-based approach similar to other OOP languages like Python, Java or JavaScript. It supports encapsulation and inheritance, allowing for more complex and organized code. R6 is known for its simplicity and ease of use for creating objects with methods and properties. If you want to learn more about R6, you can head over to Object-Oriented Programming in R with R6. R6 is not part of base R.

So where does S7 fit in? Well, S7 is actually a successor of S3 and S4. It is not part of base R, but there are long-term plans for including it. S7 tries to overcome several challenges in S3 and S4 related to their complexity, clarity and usability. We will come back to some of these point later on.

It is called a “new” OOP system for R, but it has been in the works for some time. In fact, the first commit was in May 2020, and the first release in August 2023 (version 0.1.0).

You also might have heard of something called “R7”, which is an old name for S7. However, the “R” raised the impression that this new system was a successor of R6, which it is not, or that it had something to do with R versions, which is also not the case. To avoid confusion, the “R” was replaced by “S”.

Classes, objects, methods and properties

If you’re new to OOP, all those terms like “classes”, “objects”, “methods” and “properties” may sound rather confusing. So before we dive into how to actually use S7, we’ll start with a  simple explanation of key OOP concepts in the context of S7:

  1. Class: In S7, a class is a blueprint for creating objects. It defines what an object will look like and what it can do. For example, let’s say you’re building some functionality for a smartwatch. This smartwatch contains information about the person who is wearing it. So if you have a class called Person, it might include details like name, age, and the number of steps walked on a particular day. But it can also contain actions like speak or walk.
  2. Object: An object is an instance of a class. It’s like a specific example of the class. Using the person class, an object might be Jane with the name “Jane”, age 30 and 3000 steps walked on a particular day.
  3. Properties: These are the characteristics or details of an object, such as the name , age, and steps for the Person class. Properties hold the data that belongs to an object.
  4. Method: Methods are actions or functions that an object can perform. For the Person class, methods could include speak() or walk(). They define what the object can do with its properties.

 

Now you have that knowledge, let’s make some seemingly difficult concepts understandable that are important for understanding S7:

  1. Method Dispatch: the process that determines which method to execute when a method call is made. In S7, method dispatch is straightforward, making it clear which method will be used based on the object’s class.

  2. Generics: functions that behave differently depending on the type of object they are working with. Think of them as flexible functions that adjust their behaviour based on the specific type (class) of an object you give them. In S7, generics are important for method dispatch, which decides which specific method to use for a given object.
  3. Validation: checking that the data within an object meets certain criteria or rules. S7 allows you to define these rules clearly, ensuring that objects are created and used with valid data only.

  4. Formal Definitions:  specifying exactly what an object’s class should include, such as its properties and methods. S7 uses formal definitions to make sure that classes are well-defined and consistent.

Overall, you could see S7 as a way to organize your code into formalised objects that ensure rules are clear and followed. This formalisation allows other developers to quicker understand your code, but it also helps you to keep on top of what’s happening.

Your first S7 class

In S7, you define a class with new_class(), which specifies the properties and methods for that class. Then, you create objects from this class, and these objects use the methods and properties defined in the class. This way, S7 helps you organize and manage your data and actions in a structured way. 

Let’s start building our smartwatch project that contains a Person class:

				
					library(S7)

# Create Person class
Person <- new_class(
  "Person",
  properties = list(
    name = class_character,
    age = class_numeric,
    steps = class_numeric
  )
)
				
			

The Person class is like a template that defines three pieces of information, called properties: name, age, and steps. The name is set to be a character (text), and both age and steps are numbers. So, whenever you create a Person object using this class, it will have these three properties, and they must follow the data types specified (text for name, numbers for age and steps). You can see that below:

				
					> # correct property classes
> jane <- Person(name = "Jane", age = 30, steps = 0)
> jane
<Person>
 @ name : chr "Jane"
 @ age  : num 30
 @ steps: num 0
 
> # incorrect property classes
> jane <- Person(name = 30, age = "Jane", steps = 0)
Error: <Person> object properties are invalid:
- @name must be <character>, not <double>
- @age must be <integer> or <double>, not <character>
    
> # omitting properties
> jane <- Person(name = "Jane", age = 30)
> jane
<Person>
 @ name : chr "Jane"
 @ age  : num 30
 @ steps: int(0) 
				
			

These are some first steps towards type safety: there will be an error if you’re using the incorrect datatype.

You can get and set properties using @:

				
					# set property
jane@steps <- 1000

# get property
jane@steps
				
			

Controlling properties

We can take the property definitions a little bit further too. class_character is actually short-hand for new_property(class_character), and with this new_property() function you can do all kinds of things. For example, you can specify a default value, or do some manipulations on the property before you get a value (getter) or set a value (setter):

				
					# Person class with default value and getter/setter
Person <- new_class(
  "Person",
  properties = list(
    # default: set default value
    name = new_property(class_character, 
                        default = "Unknown"),
    age = new_property(class_numeric),
    # setter: format number nicely
    steps = new_property(class_character,
                         setter = function(self, value) {
                           self@steps <- format(value, big.mark = ",")
                           self
                         }),
    # getter: read only (no setter specified)
    year_of_birth = new_property(getter = function(self) {
                          as.numeric(format(Sys.Date(), "%Y")) - self@age
                        })
  )
)

jane <- Person(age = 30, steps = 1000)
				
			

If you now look into “Jane”, you will see the following:

				
					> jane
<Person>
 @ name         : chr "Unknown"
 @ age          : num 30
 @ steps        : chr "1,000"
 @ year_of_birth: num 1994
				
			

We didn’t provide a name, so it ends up being “Unknown” because of our default. The age we leave as it, with the requirement that it has to be numeric. The steps are formatted to have a big mark with the setter. The year of birth is a read only property, because we only specified a getter (and no setter). This has little practical use in this small example, but it shows you what you can do with properties.

Properties are also called slots: the spaces or “containers” where an object’s data is stored. Each object created from a class has slots for the information that the class defines. S4 also works in a similar manner with properties, and the fact that you can formally define properties is one of the benefits of S4 and S7.

This is different from, for example, S3 which does not have slots like S7 or S4 does. In S3, objects are more flexible and loosely structured. S3 objects are basically lists with a class attribute, and their properties are just elements of the list. There’s no strict enforcement of structure or predefined “slots”.

Validation

In the above example you saw some light form of validation: if you don’t specify the correct data type of a property, you will get an error. But in S7, you can take this much further with the validator. For simplicity, we leave out the default value, setter and getter and turn back to our only requirement being a specific class, but now with a validator for the number of steps:

				
					# add validator to Person class
Person <- new_class(
  "Person",
  properties = list(
    name = class_character,
    age = class_numeric,
    steps = class_numeric
  ),
  validator = function(self) {
    if (self@steps < 0) {
      "@steps must be non-negative"
    }
  }
)
				
			

If you now provide a negative step count, you will get an error:

				
					> # test validation
> jane <- Person(name = "Jane", age = 30, steps = -1)
Error: <Person> object is invalid:
- @steps must be non-negative
				
			

Of course you can make this validation as complex as you need to. The point is that the S7 validator provides you with type safety: the validator ensures that only predefined or valid values are allowed, reducing the risk of errors caused by assigning invalid or unexpected values.

Methods and generics

Let’s say you want to construct a method that increases the step count of a specific person, Jane in this case. As mentioned earlier, methods are actions that an object can perform. In our case, this method could be called walk. 

Methods and generics are closely related in S7. A generic can be seen as a flexible interface, which uses a different implementation depending on the class of one or more arguments. This implementation is called a method, and the generic finds that appropriate method by performing method dispatch. It sounds very technical, but in the basis it looks like the following:

				
					# reset jane
jane <- Person(name = "Jane", age = 30, steps = 0)

# create new generic
walk <- new_generic("walk", "x")

# create method
method(walk, Person) <- function(x, steps) {
  x@steps <- x@steps + steps
  x
}
				
			

That looks perhaps a little bit different than you’re used to! Let’s break it down a little bit:

walk <- new_generic("walk", "x") creates a generic function called walk. Note that we use the same name here: walk! The result is assigned to a variable with the same name as the first argument. The function will work with different types of objects, and it expects at least one argument, x, which represents the object it will work on (in this case, a Person).

The line method(walk, Person) <- function(x, steps) creates a specific method for the Person class. It says, “When the walk function is used on a Person object, use this method”. This method takes two argument: the person object (x) and the number of steps they should walk (steps).

When we retrieve the step count for Jane after calling the walk method, it will be 1000:

				
					> # call method
> jane <- walk(jane, 1000)

> jane@steps
[1] 1000
				
			

You can think of much more methods for this Person class. For example, speak: 

				
					# create new generic
speak <- new_generic("speak", "x")

# create method
method(speak, Person) <- function(x) {
  paste0("Hey ", x@name, "! Your current step count is: ", x@steps)
}
				
			

Which results in:

				
					> # test method
> speak(jane)
[1] "Hey Jane! Your current step count is: 1000"
				
			

In the above examples, we implemented methods for the Person class only. But you can also imagine that you want to use the same methods for multiple classes. I mean, what if there’s a “smartwatch” for dogs that keeps track of their steps? In this case you can have a Dog class! 🙂 In any case, you can get all the methods beloning to a generic like so: 

				
					> speak
<S7_generic> speak(x, ...) with 1 methods:
1: method(speak, Person)
				
			

There’s actually much more you can do with methods and generics, and you can read more about in the detailed vignette. For now, let’s stick to the basics! At this point, it’s enough to know what they do and how you can use them.

Object-oriented programming in R with S7, is it worth it?

Perhaps you were fine with functions. They are comfy, they are the basis of R programming, and they are easy to understand. Why turn to a system like S7?

Let’s take a look at the scenario where we don’t use S7, but stick with the comfy functional programming and use a list instead:

				
					# create jane-list
jane <- list(
  name = "Jane",
  age = 30,
  steps = 0
)

# function to walk
walk <- function(x, steps) {
  x$steps <- x$steps + steps
  x
}
				
			

Now, if we want to increase Jane’s step count, we simple call the walk function:

				
					> # test function
> jane <- walk(jane, 1000)
> jane$steps
[1] 1000
				
			

So far so good, we get similar results. And while the simplicity of this approach might seem appealing, it will lead to problems when our project gets more complex. 

First of all, there’s a clear lack of a formal structure. In a list, there is zero enforcement of structure or consistency. You can accidentally modify or misspell a list element (like x$steeps instead of x$steps), and R won’t give an error. This is different when you’re using a more structured approach like S7. In S7, the structure is formally defined (with properties/slots), so if you try to access or modify a non-existent slot or assign the wrong data type, there will be an error.

Secondly, this simple list of ours doesn’t enforce the types of the elements inside them. You can accidentally assign the wrong data type to a property, like setting jane$age <- "thirty" instead of a number. S7 allows you to define property types (like class_character for text, class_numeric for numbers), which helps prevent such mistakes. You can even go further with the validator. In this case, it’s about type safety and validation.

Now imagine that you’re probably not sticking to one type of object. In the list example, we only have to worry about Jane. But what if we also to extent our smartwatch project to a dog? In the list-based approach, you can only write one version of the walk() function. If you have different types of objects (like a dog), you need to write separate functions to handle them. Perhaps a dog needs a correction for a step count because they have more legs and walk all over the place. In S7, you can use generics and methods to create different behaviour for different object types under the same function name. This makes your code more flexible and reusable.

Above point is also related to project complexity. The list is fine for a simple and small project, but as your project grows you need more structure and more rules: something S7 can provide.

So while it may seem like a hassle when you’re just getting into OOP in general and S7 in specific, it’s definitely worthwhile to incorporate object-oriented programming in R with S7. You will probably thank your future self! 

Advanced S7

So far, we just scratched the surface of what you can do with S7. I want to keep it simple, so you understand the basics and are hopefully tempted to further explore S7’s capabilities yourself. 

Here are some resources to help you get started with your advanced S7 adventure:

Wrap up

Object-oriented programming can sound daunting at first, but hopefully this article gives you some insights into the benefits. Remember that S7 is just one OOP class system, and there are others as well. Personally, I find R6 easier to use as it has more resemblance to OOP in JavaScript for example. But that’s just personal preference! In any case, you will see S7 in the future, so you might want to dip your toes in.

☕️ Was this useful to you and would you like to support me? You can buy me a coffee!

📚 Want to learn more about Shiny? Discover my online Shiny courses: ProductioniZing Shiny & CustomiZing WidgetZ.

I provide R and Shiny consultancy. Do you need help with a project? Reach out and let’s have a chat!

Geef een reactie

Je e-mailadres wordt niet gepubliceerd. Vereiste velden zijn gemarkeerd met *