-
Notifications
You must be signed in to change notification settings - Fork 1
R6_Classes_Advanced_R
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.
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
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).
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!
- 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
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"
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
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
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"
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.
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 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)
Project Workflow
Kobo API
Learning