r-lib / R6

Encapsulated object-oriented programming for R
https://R6.r-lib.org
Other
410 stars 56 forks source link

Catch unknown methods #189

Closed dkyleward closed 5 years ago

dkyleward commented 5 years ago

This is related to my stackoverflow question here: https://stackoverflow.com/questions/56668510/how-to-handle-unknown-methods-generics-in-r

In short, if you call a member of an R6 object (environment) that doesn't exist, you currently get NULL back. I'm wondering if that behavior can be modified.

The toy example below shows a "Counter" class that provides its internal count to any function called with the do method.

Counter <- R6::R6Class("Counter",
   public = list(
     count = 1,
     do = function(method_name, ...) {
       args <- list(self$count, ...)
       do.call(method_name, args)
     }
   )
)

show_count <- function(count) {
  print(paste0("The count is ", count))
}

obj <- Counter$new()
obj$do("show_count")

#> [1] "The count is 1"

Many languages have a way to make this look prettier. What I'd like to be able to use is:

obj$show_count()

When it doesn't find show_count() do a do.call(). Obviously, this wouldn't be the default behavior of an R6 object, but perhaps a special function could be built into the class that, if included in my class definition, would define what happens when an unknown method/attribute is encountered. In Python, this is the __getattr__ magic method.

Thanks for your consideration!

wch commented 5 years ago

I don't think it's something that should go in R6 itself, but you can do it by defining an S3 method for $ for the Counter class.

Here's an example that shows one way to do it:

Counter <- R6::R6Class("Counter",
  public = list(
    do = function(method_name, ...) {
      do.call(method_name, list(self, private, ...))
    }
  ),
  private = list(
    count = 1
  )
)

show_count <- function(self, private, ...) {
  txt <- paste0("The count is ", private$count, ".")

  args <- list(...)
  if (length(args) > 0) {
    txt <- paste0(
      txt,
      " Other args: '",
      paste(unlist(list(...)), collapse = "', '"),
      "'"
    )
  }

  txt
}

obj <- Counter$new()
obj$do("show_count")
#> [1] "The count is 1."
obj$do("show_count", "x", "y", "z")
#> [1] "The count is 1. Other args: 'x', 'y', 'z'"

`$.Counter` <- function(x, name) {
  if (exists(name, envir = x)) {
    .subset2(x, name)
  } else {
    function(...) {
      .subset2(x, "do")(name, ...)
    }
  }
}

obj$show_count()
#> [1] "The count is 1."
obj$show_count("x", "y", "z")
#> [1] "The count is 1. Other args: 'x', 'y', 'z'"

Some comments about it:

dkyleward commented 5 years ago

That's a great solution (and I learned a few things). Thanks for the help!

With this, I can see why R6 wouldn't need a getattr equivalent.

wch commented 5 years ago

One small update: I realized that instead of this:

  if (name %in% names(x))

It's a little better to do this:

  if (exists(name, envir = x))

I've changed the example above to reflect that.