13.3 Classes

Theory:

What is class?

  • No formal definition in S3
  • Simply set class attribute

How to set class?

  • At time of object creation
  • After object creation
# at time of object creation
x <- structure(list(), class = "my_class")

# after object creation
x <- list()
class(x) <- "my_class"

Some advice on style:

  • Rules: Can be any string
  • Advice: Consider using/including package name to avoid collision with name of another class (e.g., blob, which defines a single class; haven has labelled and haven_labelled)
  • Convention: letters and _; avoid . since it might be confused as separator between generic and class name

Practice:

How to compose a class in practice?

  • Constructor, which helps the developer create new object of target class. Provide always.
  • Validator, which checks that values in constructor are valid. May not be necessary for simple classes.
  • Helper, which helps users create new objects of target class. May be relevant only for user-facing classes.

13.3.1 Constructors

Help developers construct an object of the target class:

new_difftime <- function(x = double(), units = "secs") {
  # check inputs
  # issue generic system error if unexpected type or value
  stopifnot(is.double(x))
  units <- match.arg(units, c("secs", "mins", "hours", "days", "weeks"))

  # construct instance of target class
  structure(x,
    class = "difftime",
    units = units
  )
}

13.3.2 Validators

Contrast a constructor, aimed at quickly creating instances of a class, which only checks type of inputs …

new_factor <- function(x = integer(), levels = character()) {
  stopifnot(is.integer(x))
  stopifnot(is.character(levels))

  structure(
    x,
    levels = levels,
    class = "factor"
  )
}

# error messages are for system default and developer-facing
new_factor(1:5, "a")
#> Error in as.character.factor(x): malformed factor

… with a validator, aimed at emitting errors if inputs pose problems, which makes more expensive checks

validate_factor <- function(x) {
  values <- unclass(x)
  levels <- attr(x, "levels")

  if (!all(!is.na(values) & values > 0)) {
    stop(
      "All `x` values must be non-missing and greater than zero",
      call. = FALSE
    )
  }

  if (length(levels) < max(values)) {
    stop(
      "There must be at least as many `levels` as possible values in `x`",
      call. = FALSE
    )
  }

  x
}

# error messages are informative and user-facing
validate_factor(new_factor(1:5, "a"))
#> Error: There must be at least as many `levels` as possible values in `x`

Maybe there is a typo in the validate_factor() function? Do the integers need to start at 1 and be consecutive?

  • If not, then length(levels) < max(values) should be length(levels) < length(values), right?
  • If so, why do the integers need to start at 1 and be consecutive? And if they need to be as such, we should tell the user, right?
validate_factor(new_factor(1:3, levels = c("a", "b", "c")))
#> [1] a b c
#> Levels: a b c
validate_factor(new_factor(10:12, levels = c("a", "b", "c")))
#> Error: There must be at least as many `levels` as possible values in `x`

13.3.3 Helpers

Some desired virtues:

  • Have the same name as the class
  • Call the constructor and validator, if the latter exists.
  • Issue error informative, user-facing error messages
  • Adopt thoughtful/useful defaults or type conversion

Exercise 5 in 13.3.4

Q: Read the documentation for utils::as.roman(). How would you write a constructor for this class? Does it need a validator? What might a helper do?

A: This function transforms numeric input into Roman numbers. It is built on the integer type, which results in the following constructor.

new_roman <- function(x = integer()) {
  stopifnot(is.integer(x))
  structure(x, class = "roman")
}

The documentation tells us, that only values between 1 and 3899 are uniquely represented, which we then include in our validation function.

validate_roman <- function(x) {
  values <- unclass(x)
  
  if (any(values < 1 | values > 3899)) {
    stop(
      "Roman numbers must fall between 1 and 3899.",
      call. = FALSE
    )
  }
  x
}

For convenience, we allow the user to also pass real values to a helper function.

roman <- function(x = integer()) {
  x <- as.integer(x)
  
  validate_roman(new_roman(x))
}

# Test
roman(c(1, 753, 2024))
#> [1] I       DCCLIII MMXXIV

roman(0)
#> Error: Roman numbers must fall between 1 and 3899.