Object-Oriented Programming in R with R6

While R is primarily a functional programming language, it also offers support for object-oriented programming through various class systems, including something called R6. When I just started with R, I stayed far way from R6- simply because it seemed too advanced. That all changed when I became more proficient in JavaScript, which evolves around objects rather than functions. Work with an object-oriented programming language long enough and it feels very natural! So stop feeling overwhelmed by R6 and dive into the why and how of object-oriented programming in R with R6!

Functional Programming vs Object-Oriented Programming

In R, functional programming emphasizes the use of functions as primary building blocks. Functions can be assigned to variables, passed as arguments to other functions, and returned as values from functions. Every R package consists of functions, and you’re happily making use of them: they are at the heart of everything you do.

On the other hand, object-oriented programming (OOP) in R involves creating objects that bundle together data (variables) and functions (methods) that operate on that data. Objects are instances of classes, which define their structure and behavior. OOP promotes modular, reusable, and organized code by encapsulating related data and functions into objects. In OOP, objects are at the heart of everything you do.

To make it a bit more complicated, there are two types of OOP: class-based and prototype-based.

  • In class-based OOP, objects are created based on predefined classes. A class serves as a blueprint or template for creating objects, specifying their structure (attributes or properties) and behavior (methods or functions). Objects are instances of classes, meaning they are created from a class definition and inherit its properties and behaviors. Languages such as Java, C++, and Python predominantly use class-based OOP.
  • In prototype-based OOP, objects are created by cloning or copying existing objects, known as prototypes. Objects directly inherit properties and behaviors from their prototypes. There are no distinct classes; instead, objects serve as prototypes for creating new objects. JavaScript is an example of a language that primarily utilizes prototype-based OOP, although it also supports class-based OOP syntax starting from ECMAScript 6 (ES6).

 

In R, there are multiple OOP systems to choose from. The most important ones are S3, S4 and R6. While you can prefer one over the other for various reasons, we’re just going to stick to the goal of this article: learning object-oriented programming in R with R6. R6 incorporates class-based OOP.

Defining an R6 class

Let’s bring OOP to life with your first R6 class. To create an R6 class and methods you only need one function: R6::R6Class().

This function needs two arguments: the classname, and a list of public methods that will make up the interface you can interact with, public. Best practice is for classname to have “UpperCamelCase” names, and for methods defined in public to have “snake_case” names.

Imagine we have an R6 class called Person, with one public method: greet:

				
					Person <- R6::R6Class("Person",
  public = list(
    # Public method to greet
    greet = function() {
      cat("Hey there!")
    }
  )
)
				
			

The result of R6::R6Class is always assigned to a variable with the same name as the class. To get the Person R6 object working, you need to create a new object from the class by calling the new() method. In this case, we assign it to p, but it can literally be anything: 

				
					p <- Person$new()
p$greet()
#> Hey there!
				
			

You can override the default behaviour of new() by specifying an initialize() method: 

				
					Person <- R6::R6Class("Person",
  public = list(
	name = NULL,
	age = NULL,
    # Override default behaviour of new()
    initialize = function(name, age) {
      self$name <- name
      self$age <- age
    },
    greet = function() {
      cat("Hey, my name is", self$name, "and I am", self$age, "years old.\n")
    }
  )
)
				
			

In R6 classes, self$ is a special syntax used to access attributes and methods of the current instance of the class. In this case, we’re setting the attributes name and age, so we can set them in one method (initialize) and call them in another (greet). 

				
					p <- Person$new(name = "Jane", age = 30)
p$greet()
#> Hey, my name is Jane and I am 30 years old.
				
			

And that’s your first R6 class!

R6: encapsulated and mutable

In the context of OOP, encapsulation refers to the bundling of data (variables) and methods (functions) that operate on that data within a single unit or object. Encapsulation helps in hiding the internal implementation details of an object and only exposing necessary interfaces to interact with it. It’s like the internal functions you write in an R package: you don’t export them for the user to interact with, but you only use them internally.

In the case of R6 classes in R, encapsulation means that the internal state of an object, including its data and methods, is encapsulated within the object itself. This means that the internal state of an object can be accessed and manipulated only through the methods provided by the object’s class. “Outside code” cannot directly access or modify the internal state of an R6 object: you need a method beloning to the class.

The above means that R6 objects are mutable, but only when using the correct methods as defined by the object’s class.

Let’s demonstrate that with an example. In our previous R6 class, we set name and age as public, so it makes sense we can get that value:

				
					p$name
#> [1] "Jane"
				
			

We can also update that value:

				
					p$name <- "John"
p$greet()
#> Hey, my name is John and I am 30 years old.
				
			

Basically, the R6 object is an environment that holds all of the public members. And name and age are part of that environment, as is our greet method. We can access that environment and make modifications. But let’s take a closer look at encapsulation, where we make use of name and age as private members: 

				
					Person <- R6::R6Class("Person",
    public = list(
        initialize = function(name, age) {
            # Encapsulated private attributes
            private$name <- name
            private$age <- age
        },
        # Public method to get name
        getName = function() {
            private$name
        },
        # Public method to update name
        setName = function(new_name) {
            private$name <- new_name
        },
        # Public method to greet
        greet = function() {
            cat("Hey, my name is", private$name, "and I am", private$age, "years old.\n")
        }
    ),
    private = list(
        name = NULL,
        age = NULL
    )
)
				
			

Trying to retrieve the name, results in nothing: 

				
					p <- Person$new("Jane", 30)
p$name
#> NULL
				
			

But we can retrieve the name by using the method:

				
					p$getName()
#> "Jane"
				
			

Setting the name like we did we did with the public members also won’t work, we need methods like setName

				
					p$setName("John")
p$greet()
#> Hey, my name is John and I am 30 years old.
				
			

Above example demonstrates both encapsulation and mutability:

  • The name and age are encapsulated within the private environment called private. They can only be accessed and modified through the public methods (getName, setName).
  • Attempting to access the private attributes directly with “outside code” will result in NULL.

Why bother?

Seems nice, but perhaps still complicated, so why should you care about OOP and using R6 in R? Can’t we just use functions?

While functions play a crucial role in R programming and functional programming paradigms, they have inherent limitations that make them less suitable for certain tasks compared to OOP with R6. Functions lack encapsulation and they are stateless by nature: they do not inherently bundle data and behaviors together and they do not maintain internal state between function calls. And if you’re used to OOP, you may also use R6 as a way to organize your code better. With R6 classes, you can group related data and methods together within a class definition, leading to clearer and more modular code. Functions, on the other hand, may result in a less organized and more fragmented codebase, especially in larger projects.

That doesn’t mean you’re completely abandoning functions in OOP! On the contrary, you still define methods as functions and you constantly call these methods on an object. In OOP, functions still have a prominent place.

R6 in the wild

So where do you encounter R6? Enough places, and certainly in packages developed for Shiny!

For example, my latest package fireworks uses R6 to construct the “Fireworks” class and start and stop fireworks in a Shiny app. The R6 class looks like this:

				
					Fireworks <- R6::R6Class(
	"Fireworks",
	public = list(
		initialize = function(id = NULL, session = shiny::getDefaultReactiveDomain(), options = list()){
			private$.id <- id
			private$.session <- session
			private$.options <- options
		},
		start = function(){
			if (is.null(private$.id)) {
				private$.session$sendCustomMessage("fireworks-start", list(options = private$.options))
			} else {
				for (i in 1:length(private$.id)) {
					msg <- list(
						id = private$.id[[i]],
						options = private$.options
					)
					private$.session$sendCustomMessage("fireworks-start", msg)
				}
			}
			invisible(self)
		},
		stop = function(){
			if (is.null(private$.id)) {
				private$.session$sendCustomMessage("fireworks-stop", list())
			} else {
				for (i in 1:length(private$.id)) {
					private$.session$sendCustomMessage("fireworks-stop", list(id = private$.id[[i]]))
				}
			}
			invisible(self)
		}
		),
	private = list(
		.id = NULL,
		.session = NULL,
		.options = NULL
		)
)
				
			

This makes use of a private environment, and has public methods to start and stop the fireworks.

Other examples can be found in shinyvalidate, where the InputValidator is an R6 class, and waiter, where Waiter itself is an R6 class too.

Shiny applications often involve managing complex states, such as user inputs, session variables, and reactive expressions. R6’s mutable objects make it easy to manage and update state within the application, as objects can be modified dynamically using their methods. Besides that, you can easily create multiple instances, and with R6 you can easily track and update state of each individual instance of your R6 class. In the context of Shiny, this means that you can tailor behaviour for each single element in your application.

R6 vs RC

When reading about OOP and R6 in R, you might also encounter an OOP system called reference class (RC). And while both R6 and RC are very similar, you shouldn’t confuse the two: they are different things! RC is a built-in system, and R6 comes from the R6 package. The differences between the two are quite technical, but overall RC is more complicated and slower compared to R6. The fact that R6 is provided by a separate package also means that you don’t have to upgrade your whole R version for bug fixes.

Advanced usage of R6

Now you got introduced to R6 I’m sure that you’re not far away from implementing it yourself. We only scratched the surface of what you can do with R6 – just enough to get you started. But obviously, there’s much more to explore when it comes to R6 classes, like chaining, active bindings, finalizers… I’ll have to leave that for another article before it starts to look like a book 😉. In the meantime, if you are interested in R6, I would recommend checking out these two resources:

 

Enough to explore!

Wrap up

Hopefully this article made R6 sound less complicated, and I’m looking forward to see your adventures with R6 in the future.

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

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

Geef een reactie

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