OOP in R: S3

RSE
Rpkg

Notes on learning OOP in R, and trying to figure out the situation where I can benefit from using OOP.

Author

Chi Zhang

Published

February 23, 2023

Useful references:

Object oriented programing

Why use OOP: polymorphism - same function can be used on different types of input.

Without OOP, if someone wants to add new functionality to for example summary(), he needs to ask the original author to change if-else statements inside the summary function. OOP allows any developer to extend the interface for new types of input.

Terminology

  • Object: individual instances of a class
  • Class: type of an object, i.e. what an object is
  • Method: a function associated with a particular class, i.e. what the object can do
    • generic method: mean() of a vector of numbers is a number, mean() of a vector of dates is a date
    • Inherit: a sub-class inherits all the attributes and methods from the super-class. E.g. generalized linear model inherits from a linear model.
    • method dispatch: the process of finding the correct method given a class

Encapsulated OOP:

  • methods belong to object or classes
  • object.method(arg1, arg2)
  • common in most languages
  • R6, RC (reference class) are examples of this type

Functional OOP:

  • methods belong to generic functions
  • generic(object, arg2, arg3)
  • S3 is an informal implementation of this type

Base types

Check whether an object is object-oriented, or base object:

  • is.object()
  • sloop::otype(): returns base or S3/S4
  • attr(obj_name, 'class'): OO objects has a class attribute, BO does not.
x <- 1:10 # a numeric vector
y <- factor(c('a', 'b'))  # a factor

c(is.object(x), is.object(y))
[1] FALSE  TRUE
c(sloop::otype(x), sloop::otype(y))
[1] "base" "S3"  
attr(x, 'class') 
NULL
attr(y, 'class')
[1] "factor"

All objects have a base type; not all are OO objects.

  • typeof(1:10) returns ‘integer’
  • 25 base types in total
    • vectors: e.g. NULL, logical, integer, double, complex, character, list, raw
    • functions: e.g. closure, special, builtin
    • environments: environment
    • S4: S4
    • language components, symbol, language, pairlist the rest are less common.

S3

Allows the function to return rich results with user-friendly display and programmer-friendly internals.

y <- factor(c('a','b'))
typeof(y)
[1] "integer"
attributes(y)
$levels
[1] "a" "b"

$class
[1] "factor"

Check whether a function is a generic: sloop::ftype()

sloop::ftype(print)
[1] "S3"      "generic"
sloop::ftype(`+`)
[1] "primitive" "generic"  

The generic finds the method (implementation of print(), summary() for a specific class) by performing method dispatch.

y <- factor(c('a','b'))
sloop::s3_dispatch(print(y))
=> print.factor
 * print.default

Generic or method?

  • generic.class(), for example: print.factor()
  • do not call the method directly; use the generic (dispatch) to find it.
  • generally has the . in the name; however it is not guaranteed * t.test() is a generic like print(), as t.test() can be used on multiple types of inputs
    • as.factor() is not an OO object, hence not S3

Check function type with sloop::ftype()

sloop::ftype(predict) # predict is a generic
[1] "S3"      "generic"
sloop::ftype(predict.glm)  # glm (class) method for predict() generic
[1] "S3"     "method"

Check methods with methods()

methods() checks all the methods that either:

  • belongs to a generic (the function), such as plot, predict, t.test
  • belongs to a class (the type of input), such as lm, ar
methods('predict')  
 [1] predict.ar*                predict.Arima*            
 [3] predict.arima0*            predict.glm*              
 [5] predict.HoltWinters*       predict.lm*               
 [7] predict.loess*             predict.mlm*              
 [9] predict.nls*               predict.poly*             
[11] predict.ppr*               predict.prcomp*           
[13] predict.princomp*          predict.smooth.spline*    
[15] predict.smooth.spline.fit* predict.StructTS*         
see '?methods' for accessing help and source code
methods(class = 'lm')
 [1] add1           alias          anova          case.names     coerce        
 [6] confint        cooks.distance deviance       dfbeta         dfbetas       
[11] drop1          dummy.coef     effects        extractAIC     family        
[16] formula        hatvalues      influence      initialize     kappa         
[21] labels         logLik         model.frame    model.matrix   nobs          
[26] plot           predict        print          proj           qr            
[31] residuals      rstandard      rstudent       show           simulate      
[36] slotsFromS3    summary        variable.names vcov          
see '?methods' for accessing help and source code

Equivalently, use sloop::s3_methods_*(), as it gives more information in the output.

sloop::s3_methods_generic('predict') 
# A tibble: 16 × 4
   generic class             visible source             
   <chr>   <chr>             <lgl>   <chr>              
 1 predict ar                FALSE   registered S3method
 2 predict Arima             FALSE   registered S3method
 3 predict arima0            FALSE   registered S3method
 4 predict glm               FALSE   registered S3method
 5 predict HoltWinters       FALSE   registered S3method
 6 predict lm                FALSE   registered S3method
 7 predict loess             FALSE   registered S3method
 8 predict mlm               FALSE   registered S3method
 9 predict nls               FALSE   registered S3method
10 predict poly              FALSE   registered S3method
11 predict ppr               FALSE   registered S3method
12 predict prcomp            FALSE   registered S3method
13 predict princomp          FALSE   registered S3method
14 predict smooth.spline     FALSE   registered S3method
15 predict smooth.spline.fit FALSE   registered S3method
16 predict StructTS          FALSE   registered S3method
sloop::s3_methods_class('lm')
# A tibble: 35 × 4
   generic        class visible source             
   <chr>          <chr> <lgl>   <chr>              
 1 add1           lm    FALSE   registered S3method
 2 alias          lm    FALSE   registered S3method
 3 anova          lm    FALSE   registered S3method
 4 case.names     lm    FALSE   registered S3method
 5 confint        lm    FALSE   registered S3method
 6 cooks.distance lm    FALSE   registered S3method
 7 deviance       lm    FALSE   registered S3method
 8 dfbeta         lm    FALSE   registered S3method
 9 dfbetas        lm    FALSE   registered S3method
10 drop1          lm    FALSE   registered S3method
# ℹ 25 more rows

Class assignment

Two options: structure(), or class(existing_obj)

simple_number <- structure(1, class = 'simple')
class(simple_number)
[1] "simple"

Or, you can do it for an existing object by giving it a class

simple_char <- 'your_name'
class(simple_char) <- 'simple'
class(simple_char)
[1] "simple"

Constructor

fruit <- function(x){
  stopifnot(is.character(x))
  # checks if x is char
  # better use a named list, easier to call
  structure(list(fruit_name = x), class = 'fruit') 
}

fruit1 <- fruit('pineapple')
fruit2 <- fruit('apple')

Examine what comes out

fruit1
$fruit_name
[1] "pineapple"

attr(,"class")
[1] "fruit"

Define new generic and method

[name of method] <- functionn(x){UseMethod("[name of method]")}

Now we define one generic function f, and two methods. One for class plus2, and another for class plus10.

f <- function(x){UseMethod('f')} # define generic f
f.plus2 <- function(x) x+2 # f method for class plus2
f.plus10 <- function(x) x+10 # f method for class plus10

Now we try to give the function some input. First use a numeric number, 1 (the class for a number is double and numeric).

number <- 1
f(number) # returns error, class of number does not match!
Error in UseMethod("f"): no applicable method for 'f' applied to an object of class "c('double', 'numeric')"

This returns an error, because the class of number is not defined for function f (plus2, plus10).

# can check what f(number) tried 
# none of these exist 
sloop::s3_dispatch(f(number))
   f.double
   f.numeric
   f.default

We need to match it. Assign the number with plus2 class, and evaluate it. You can check which method has been used (dispatched).

# fix: assign a class to number
class(number) <- 'plus2'
f(number) # number+2, f.plus2 method
[1] 3
attr(,"class")
[1] "plus2"
sloop::s3_dispatch(f(number))
=> f.plus2
   f.default

Now we try another number, but let it be plus10 class.

numberx <- 200
class(numberx) <- 'plus10'
f(numberx)
[1] 210
attr(,"class")
[1] "plus10"
sloop::s3_dispatch(f(numberx))
=> f.plus10
   f.default

New method for existing generic (print())

We create the S3 object using the constructor defined above, fruit().

pineapple <- fruit('pineapple') # create by the constructor
pineapple
$fruit_name
[1] "pineapple"

attr(,"class")
[1] "fruit"

The output does not look very nice, we can modify what prints out. Since print() is an exisiting generic function, we do not need to define a new one (i.e. UseMethod). We define the new method directly: generic.your_class.

# we do not need to define print() as generic, bec it IS already
# directly define print.fruit
print.fruit <- function(x){
  cat('I used constructor for my fruit:', x$fruit_name)
}

print.fruit(pineapple)
I used constructor for my fruit: pineapple