The oops
package simplifies the creation of classes with reference semantics in R. Though similar to refclasses or R6
classes, oops
classes are designed to integrate seamlessly into the R ecosystem by behaving similar to objects such as lists. In this tutorial, I walk through the creation and use of oClasses.
First, we need to load the oops
library.
library(oops)
Let us start by creating a simple class generator using the oClass(...)
function. Our class will be an economic agent that holds cash that it can transfer to others.
The oClass
function has a number of arguments to control the how the class is built. The most important is its name. This is not strictly necessary, but defines how S3 methods won’t be properly deployed without it. General named arguments, such as cash
below, can be passed to oClass
to be populated within the class instance.
<- oClass(
Agent "Agent",
cash = 0
)
Agent
is now a function that generates oClass instances of class "Agent"
. Calling Agent
prints the oClass name, the generator’s formal arguments, and its default objects.
Agent#> oClass::Agent(...)
#> cash: <numeric> 0
To create an Agent instance, we just call the generator function.
<- Agent()
agent1 <- Agent()
agent2
agent1#> <Agent: 0x7fd32f5bada0>
#>
Note that printing agent1
is uninformative; this is due in part to agent1
actually being empty. The cash variable is linked to the Agent
generator, not the individual instance. However, the generator is in each instance’s search path, so agent1
can access cash.
$cash
agent1#> [1] 0
This setup reduces the amount of copying that occurs when creating an instance; only a pointer to the generator is needed. If the underlying cash variable of the generator is changed, though, both instances automatically incorporate it.
$cash <- 10
Agent$cash
agent1#> [1] 10
Changing the cash variable for an instance does not impact other instances though.
$cash <- 250
agent1$cash
agent1#> [1] 250
$cash
agent2#> [1] 10
Similarly, adding a variable to the generator gives all instances access to it, even after creation. Though this is only operative if the instance does not already have access to the variable.
$items <- list("computer", "code")
agent2
$items <- list()
Agent$items
agent1#> list()
$items
agent2#> [[1]]
#> [1] "computer"
#>
#> [[2]]
#> [1] "code"
Our agents currently have access to cash, but we may need a quick way to identify each one other than its current holdings. We could create an “id” field in the generator. However, each instance will inherit the same value. An alternative solution is to use the init(x, ...)
function.
init
is automatically called on each newly created oClass instance. This generally adds all named variables passed through ...
during the generator call to new instance. But, this behavior can be modified by creating a custom init
method for the oClass.
For our custom initialization function, we will create a random number and assign it to the id.
<- function(x, ...){
init.Agent $id <- round(runif(1) * 10000)
xinit_next(x, ...)
return(x)
}
The init
function also contains a call to init_next
. This calls the init
method of the “superclass” of the object, or the next class. Since the class of each agent is c("Agent", "Instance")
, init_next
calls the init.Instance
method. This adds the contents of ...
to instance.
# Create new agent and check id
<- Agent()
agent1 $id
agent1#> [1] 2512
# Create a new agent with extra field (initialized through init.Instance)
<- Agent(name = "Dave")
agent2 $id # different from agent1
agent2#> [1] 4411
$name
agent2#> [1] "Dave"
As we saw above, printing an Agent is not very informative. We can change this by adding a print method in the exact same way as other classes.
<- function(x, ...){
print.Agent <- paste0("Agent, #", x$id, ": $", x$cash, "\n")
text cat(text)
}
agent1#> Agent, #2512: $10
agent2#> Agent, #4411: $10
One of the key differences between oops
classes and standard lists is that they are passed by reference so that changes to the object within a function persist afterwards.
To see this, let us create a function that transfers money between different agents. The function will take two different agents and a cash amount. Since we have given our Agent a name, we can create an Agent-specific method.
<- function(from, to, amount) UseMethod("transfer")
transfer <- function(from, to, amount){
transfer.Agent $cash <- from$cash - amount
from$cash <- to$cash + amount
to }
Now, let us test out the function by transferring 2 dollars between agent1
and agent2
.
transfer(agent1, agent2, 2)
agent1#> Agent, #2512: $8
agent2#> Agent, #4411: $12
It worked! The results of the transfer persisted outside of the function and there was no need to return the two agents.
This can be very dangerous and breaks with the philosophy of R because it may lead to side effects that are not obvious. For example, another function may call transfer and lead to a permanent change when you only want it to be temporary. One solution is to clone
the agent, which makes a copy of the instance.
<- clone(agent1)
fake_agent1
agent1#> Agent, #2512: $8
fake_agent1#> Agent, #2512: $8
identical(agent1, fake_agent1)
#> [1] FALSE
Note that clone
only makes a copy of the agent, not any other reference objects such as environments or oops
instances that the agent references.
"partner"]] <- agent2
agent1[[<- clone(agent1)
fake_agent1
identical(agent1$partner, fake_agent1$partner) # Same!
#> [1] TRUE
Use deep=TRUE
to make a deep clone of the instance. This will create a new copy of each environment within the instance. clone
does keep track of any cloned environments to prevent infinite recursion and ensure that multiple points to the same environment behave correctly after cloning.
<- clone(agent1, deep=TRUE)
fake_agent1 identical(agent1$partner, fake_agent1$partner) # Different!
#> [1] FALSE
oClasses
have support for nested, linear inheritance of other oClass objects, just pass the parent generator to inherit
during oClass creation. Let us create “Household” class that inherits from “Agent”, but also has rent to pay.
<- oClass(
Household "Household",
inherit = Agent,
rent = 1000
)
All created instances of “Household” now have access to the rent
variable as well as the cash
variable from “Agent”. It also uses all Agent methods such as the transfer
function created earlier. This includes the init.Agent
function that will automatically be called on each new Household instance until init.Household
is defined.
<- Household()
house
# Variables
$rent # from "Household"
house#> [1] 1000
$cash # from "Agent"
house#> [1] 10
$id # from "Init.Agent"
house#> [1] 5560
# Using an Agent Method
transfer(house, agent1, -2000)
$cash
house#> [1] 2010
Let us create a new function that is specific to Household: paying rent.
# create pay_rent function that reduces cash by rent amount
<- function(x) UseMethod("pay_rent")
pay_rent <- function(x){
pay_rent.Household $cash <- x$cash - x$rent
xprint(paste0("Household paid $", x$rent, " in rent and now has $", x$cash))
}
# pay rent :(
pay_rent(house)
#> [1] "Household paid $1000 in rent and now has $1010"
Agents, though, can’t pay rent since they are not Households.
## DON'T RUN:
## pay_rent(agent1)
##
## Error in UseMethod("pay_rent") :
## no applicable method for 'pay_rent' applied to an object of class "c('Agent', 'Instance')"
It may be desirable to change the formal arguments of the generator function to better instance creation. This is done by passing a list to formals
at the time of generator creation. The list should be equivalent to the argument list used when creating a function.
<- oClass(
Household "Household",
inherit = Agent,
formals = list(rent, ...)
)
Household#> oClass::Household(rent, ...)
#>
Since the formal argument list is (rent, ...)
, rent must be specified each time that an instance is created, otherwise you will get an error. Note that the dots argument, ...
, is not necessary if you want to ensure that extra variables are not added by the user at creation.
When specifying formals, an associated init
method should be created with identical arguments except for the instance. If not, then an error will likely be generated each time you attempt to create an instance.
<- function(x, rent, ...){
init.Household $rent <- rent
xinit_next(x, ...)
x
}
<- Household(500)
house $rent
house#> [1] 500
If the formals need to be changed later, you can use the change_formals
function. The resulting generator function must be assigned for the changes to take hold.
# Update the 'init' method
<- function(x, rent = 1000, cash = 5000){
init.Household $rent <- rent
x$cash <- cash
x
x
}
# Change the formals list
<- change_formals(Household, from_init=init.Household)
Household
Household #> oClass::Household(rent = 1000, cash = 5000)
#>
# Create new instance
<- Household()
house $rent
house#> [1] 1000
The default behavior of oClass
is that each instance only references objects from the template environment of the generator function. This reduces copying and can greatly speed up the creation time of complex objects. The side effect, though, is that changes to the generator function carry over to instances, even those already created. The other issue is that exporting instances are unlikely to carry working references to the generator function.
These issues can be solved by including portable=TRUE
when calling oClass
. This copies all objects into the instance and removes the generators from the search path.
<- oClass(
Agent "Agent",
cash = 0,
portable = TRUE
)
<- Agent()
agent1 ls(agent1) # includes "cash" from "Agent"
#> [1] "cash" "id"
parent.env(agent1)
#> <environment: base>