Skip to content

R6_Classes_Advanced_R

Frie edited this page Mar 18, 2021 · 2 revisions

R6 Classes - Advanced R Walkthrough

This is a walkthrough (without exercises) of Advanced R - Chapter 14 by Hadley Wickham about R6 Classes. If not otherwise specified, quotes are from this chapter.

Creative Commons Licence

Adapting the share-alike license of Advanced R, this walkthrough is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

The code is available under the MIT license

Copy-on-modify vs. modify-in-place

library(R6)

R6 objects have reference semantics which means that they are modified in-place, not copied-on-modify.

typically, R uses a paradigm called “copy-on-modify”, i.e. it makes a copy whenever you modify an object:

x <- c(1, 2, 3)
y <- x

# x and y point to the same object in-memory
# cf https://adv-r.hadley.nz/names-values.html#binding-basics
addr_x <- lobstr::obj_addr(x)
addr_y <- lobstr::obj_addr(y)

addr_x == addr_y # TRUE
## [1] TRUE
# now when we modify y, R makes a copy! 
y[[3]] <- 4
addr_y_after_modify <- lobstr::obj_addr(y)
addr_y_after_modify
## [1] "0x7fc15cc59028"
addr_y == addr_y_after_modify # FALSE 
## [1] FALSE

R6 classes constitute an exemption to this rule because they are modified in place and use reference semantics. This is not something we often run into when using R except in certain circumstances (see here), so it might feel a bit foreign at first.

# this creates a class with a public field x which equals five 
myclass <- R6::R6Class(public = list(c = 5))

# we create two instances of our class 
instance_1 <- myclass$new() 
instance_2 <- instance_1

old_addr <- lobstr::obj_addr(instance_1) 
# instance_2 points to the same object in memory
lobstr::obj_addr(instance_1) == lobstr::obj_addr(instance_2) 
## [1] TRUE
# modify the first instance
instance_1$c <- 2 * 5 # modify in place, not copy on modify!
instance_1$c # 10 
## [1] 10
# instance_1 still stored at the old address -> R did not copy it somewhere else
lobstr::obj_addr(instance_1) == old_addr
## [1] TRUE
# remember, we only changed instance_1 so far!!
instance_2$c # 10, huh? reference semantics!
## [1] 10
old_addr == lobstr::obj_addr(instance_2)
## [1] TRUE

Check out the section on environments for a visual representation of what is happening here (replace e1 and e2 with instance_1 and instance_2 in your head).

Classes, fields and methods

Defining a class

R6 only needs a single function call to create both the class and its methods: R6::R6Class(). This is the only function from the package that you’ll ever use!

the first argument: classname

  • not needed but improves error messages
  • in UpperCamelCase
Client <- R6::R6Class(classname = "KoboClient")
Client
## <KoboClient> object generator
##   Public:
##     clone: function (deep = FALSE) 
##   Parent env: <environment: R_GlobalEnv>
##   Locked objects: TRUE
##   Locked class: FALSE
##   Portable: TRUE

second argument: public

contains the list of …

  • fields
  • methods (aka R functions)

in snake_case. It is implemented as a named list.

We can access the methods and fields using the self$.

Client <- R6::R6Class(
  classname = "KoboClient",
  public = list(
    user_first_name = "Correl",
    user_last_name = "Aid",
    greeting_count = 0,
    greet = function(greeting = "Hi") {
      print(paste(greeting, self$user_first_name, self$user_last_name))
      self$greeting_count <- self$greeting_count + 1
      invisible(self)
    }
  )
)

You should always assign the result of R6Class() into a variable with the same name as the class, because R6Class() returns an R6 object that defines the class

If we look at the Client object, it is a special “object generator” object that apparently can create KoboClient objects: 👀

Client
## <KoboClient> object generator
##   Public:
##     user_first_name: Correl
##     user_last_name: Aid
##     greeting_count: 0
##     greet: function (greeting = "Hi") 
##     clone: function (deep = FALSE) 
##   Parent env: <environment: R_GlobalEnv>
##   Locked objects: TRUE
##   Locked class: FALSE
##   Portable: TRUE

We can now use the new() method of this object to make new instances of our class:

o1 <- Client$new()
o1$greet("Hi")
## [1] "Hi Correl Aid"

$print() and $initialize()

We can define a custom $print() method for our class:

Client <- R6::R6Class(
  classname = "KoboClient",
  public = list(
    user_first_name = "Correl",
    user_last_name = "Aid",
    greeting_count = 0,
    greet = function(greeting = "Hi") {
      print(paste(greeting, self$user_first_name, self$user_last_name))
      self$greeting_count <- self$greeting_count + 1
      invisible(self)
    },
    print = function() {
      cat("Client: \n")
      cat("  User: ", self$user_first_name, "\n", sep = "")
    }
  )
)
o <- Client$new()

with $initialize(), we can override the behavior of $new() so that users can also pass arguments when creating an instance:

# this errors with the old class
# o <- Client$new(first_name = "Correliiiii")
Client <- R6::R6Class(
  classname = "KoboClient",
  public = list(
    user_first_name = NA,
    user_last_name = "Aid",
    greeting_count = 0,
    initialize = function(first_name = "Correl") { # we can set a default argument
      # we can also have validity checks here
      self$user_first_name <- first_name
    },
    print = function() {
      cat("Client: \n")
      cat("  User: ", self$user_first_name, "\n", sep = "")
      invisible(self)
    },
    greet = function(greeting = "Hi") { 
      print(paste(greeting, self$user_first_name, self$user_last_name))
      self$greeting_count <- self$greeting_count + 1
      invisible(self)
    }
  )
)
o <- Client$new(first_name = "Correliiiii")
o$greet("Hello")
## [1] "Hello Correliiiii Aid"
o$greeting_count
## [1] 1

Why do we return invisible(self)?

For methods that are mostly called for side-effects, we should always return the object itself invisibly. This allows for method chaining (very similar to the pipe!):

o <- Client$new()
o$greet("Hello")$greet("Hi")$greet("Hey")$greeting_count
## [1] "Hello Correl Aid"
## [1] "Hi Correl Aid"
## [1] "Hey Correl Aid"

## [1] 3
# or.. even more pipe like :) 
o$
  greet("Hello again")$
  greet("Hi again")$
  greet("Hey again")$
  greeting_count
## [1] "Hello again Correl Aid"
## [1] "Hi again Correl Aid"
## [1] "Hey again Correl Aid"

## [1] 6

Using set to add methods after creation

This is useful when exploring interactively, or when you have a class with many functions that you’d like to break up into pieces. Add new elements to an existing class with $set(), supplying the visibility (more on in Section 14.3), the name, and the component.

–> this could be relevant for our package because we could then define functions outside of the class, making (probably) for better readable code

Client$set("public", "kobo_api_version", 2)
# of course we could also add new methods 
my_fun <- function(x) {
  print("just a silly function")
  invisible(self)
}
Client$set("public", "my_function", my_fun) # we should probably keep the names consistent but we don't have to :shrug: 

# we have  to create a new object if we want to have those new methods / fields
# because we only added the new field to the Generator, not the old objects
o_new <- Client$new()
o_new$kobo_api_version
## [1] 2
o_new$my_function()
## [1] "just a silly function"

Inheritance

We can inherit behaviour from other classes by setting the inherit argument to the class object of the class we want to inherit from:

ClientOldAPI <- R6::R6Class("ClientV1", inherit = Client,
                            public = list(
                              kobo_api_version = 1, # we can override things 
                              something_specific = "bla bla", # or add new fields or functions
                              function_needed_for_v1 = function() {
                                print("this is needed for the old version of the API")
                                invisible(self)
                              },
                              greet = function(greeting = "HI") {
                                print("Hello from version 1.")
                                super$greet(greeting)
                              }  
                            ))

x <- ClientOldAPI$new(first_name = "Frie")
x$greet() # the functions inherited still work as previously
## [1] "Hello from version 1."
## [1] "HI Frie Aid"
x$kobo_api_version
## [1] 1
x$function_needed_for_v1()
## [1] "this is needed for the old version of the API"

even if we have overridden a method from the parent class (like here with greet), we can still use super$ to use the parent class implementation.

Controlling access: private and active fields

Private fields and methods

With R6 you can define private fields and methods, elements that can only be accessed from within the class, not from the outside

We have to use private$ instead of self$ to refer to the fields / methods.

Client <- R6::R6Class(
  classname = "KoboClient",
  private = list(
    user_first_name = NA
  ),
  public = list(
    user_last_name = "Aid",
    greeting_count = 0,
    initialize = function(first_name = "Correl") { # we can set a default argument
      # we can also have validity checks here
      private$user_first_name <- first_name 
    },
    greet = function(greeting = "Hi") { 
      print(paste(greeting, private$user_first_name, self$user_last_name))
      self$greeting_count <- self$greeting_count + 1
      invisible(self)
    }
  )
)

o <- Client$new()
o$user_first_name # accessing a private field yields NULL (makes sense: it doesn't exist in the public list)
## NULL
o$greet("Hallo") # public methods can still use the field
## [1] "Hallo Correl Aid"

Active fields

Active fields allow you to define components that look like fields from the outside, but are defined with functions, like methods.

  • implemented using active bindings
  • are implemented with a function that takes an argument value

Active fields are particularly useful in conjunction with private fields, because they make it possible to implement components that look like fields from the outside but provide additional checks.

we can also use them to make read-only private fields:

Person <- R6::R6Class(
  "Person",
  public = list(name = NA,
                initialize = function(name, age, kg) {
                  self$name <- name
                  private$.age <- age
                  private$.weight <- kg
                }),
  private = list(.age = NA, # use the dots to distinguish from active binding
                 .weight = NA),
  active = list(
    age = function(value) {
      if (!missing(value)) {
        stop("You can't set age")
      } else {
        return(private$.age)
      }
    }
  )
)

p <- Person$new("Bob", 42, 80)
p$name # public
## [1] "Bob"
p$weight # NULL, not accessible because private
## NULL
p$age # accesses .age through the active field
## [1] 42
# p$age <- 22 # fails because read-only

we can also use active fields when we want to restrict the user’s capability when setting a public field: we make the field private and instead provide the user with an active binding where we can have checks for the user input. (see the chapter for an example)