R6 icon indicating copy to clipboard operation
R6 copied to clipboard

Nested member functions & recursive `self`-binding

Open mmuurr opened this issue 2 years ago • 6 comments

I think this counts as a feature request, unless there's a simple pattern that exists to achieve the desired result that I haven't yet stumbled upon. I've found some instances where it'd be useful to 'nest' methods (mostly for clean naming, sometimes in codegen situations). In spirit, something like this, where f1 is not a proper member function, but instead a function within a member field:

Klass <- R6::R6Class("Klass",
  public = list(
    x = 0,
    initialize = function(x) self$x <- x,
    funlist = list(
      f1 = function() self$x
    )
  )
)
obj <- Klass$new(1)
obj$funlist$f1()  ## object 'self' not found

But the self environment-binding during construction only sees funlist as a list, not as a container of objects into which one might recurse and continue the self environment-binding. (Same observation for any other named & bound environments, like private.)

One way around this would be implement a special type of list, something akin to (or exactly):

memberlist <- function(...) structure(list(...), class = "R6.memberlist")

Now, when examining a new instance's members to determine if each is a data field or method for self-binding, one could recurse in the special case of a "R6.memberlist" field:

    ...
    funlist = R6::memberlist(
      f1 = function() self$x
    )
    ...

I realize this is pretty specialized behavior, but ES6 Classes have a few ways to achieve this, including arrow functions (since an arrowfun's this is bound to the syntactically-defined container wrapping the function):

class Klass {
  x = 0;
  constructor(x) {
    this.x = x
  };
  funlist = {
    f1_traditional_nobind: function() {
      return this.x;
    },
    f1_traditional_iifebind: (function() {
      return this.x;
    }).bind(this),
    f1_arrow: () => this.x
  };
};
obj = new Klass(1);
obj.funlist.f1_traditional_nobind();    // undefined because of unbound `this`
obj.funlist.f1_traditional_iifebind();  // works, though a bit ugly/obfuscated
obj.funlist.f1_arrow();                 // works (though don't try this with ES5 'classes')

(There are other ways to use .bind(...) to get there, too, but they start to feel pretty convoluted.)

Again, this feels like pretty low priority and perhaps helps a very small number of R6 users, but also would be a neat extension to allow a tad more flexibility to the self/private-binding process :-)

In any case, thanks for all the existing work on R6!

mmuurr avatar Oct 20 '21 04:10 mmuurr

Oh, I faced exactly the same issue today. Is there any other workaround? Is there a way to access the enclosing "parent" environment from f1()? Here is my tiny reprex:

library(R6)

Person <- R6Class("Person",
    public = list(
        name = NULL,
        level1 = list(
            level2 = list(
                level3 = list(
                    method = function() {
                        print(self$name)
                    }
                )
            )
        ),
        initialize = function(name) {
            self$name = name
        }
    )
)

jay = Person$new(name = "Jay")
jay$level1$level2$level3$method()

irudnyts avatar Nov 13 '23 23:11 irudnyts

A workaround is to declare those nested functions at the top level of public or private, then assign them into the nested location in initialize(). Another one (that's a little fragile) is to create a top-level method that modifies a function to make it into a method, and call that in initialize(). I say it's fragile, because it depends on the current implementation of methods in R6, but I'd guess that's unlikely to change.

Here's an example of the second approach. I've edited it from my original post, which wouldn't work with classes that had private methods. For the first approach, see your post on SO: https://stackoverflow.com/q/77477472 .

library(R6)

Person <- R6Class("Person",
                  public = list(
                    name = NULL,
                    
                    level1 = list(
                      level2 = list(
                        level3 = list()
                      )
                    ),
                    
                    initialize = function(name) {
                      self$name = name
                      self$level1$level2$level3$method <- 
                        private$makeMethod(function() {
                          print(self$name)
                        })
                    }
                  ),
                  private = list(
                    makeMethod = function(f) {
                      env <- new.env(parent = environment(f))
                      myenv <- parent.env(environment())
                      
                      for (name in ls(myenv))
                        env[[name]] <- get(name, envir = myenv)
                      environment(f) <- env
                      f
                    }
                  )
)

jay = Person$new(name = "Jay")
jay$level1$level2$level3$method()
#> [1] "Jay"

Created on 2023-11-14 with reprex v2.0.2

dmurdoch avatar Nov 14 '23 10:11 dmurdoch

Thanks for your replies and approaches. I'm rather fond of the one on the SO. Do you think it's an ok-ish idea to develop a package using this approach?

irudnyts avatar Nov 15 '23 17:11 irudnyts

I think the SO one is really likely to survive R6 and R updates.

A harder question is whether that's a good way to define a class. An alternative would be to replace the nested list with a single public method, called as jay$method("level1", "level2", "level3"). I think that would have better documentation support in Roxygen. If you are thinking the list might change dynamically, it would make more sense to leave it as a list.

dmurdoch avatar Nov 15 '23 18:11 dmurdoch

I prefer it because of the syntax. I'm mimicking a Python API package, which has nested attributes/methods. E.g., I'm trying to represent a Python method call object.level1.level2.method() with object$level1$level2$method().

Of course, one workaround would be simply calling methods in R in the following manner obj$level1_level2_method(). But from the aestetical point of view it is not really desirable 🙂

irudnyts avatar Nov 15 '23 19:11 irudnyts

Btw, if I'm not mistaken, Reference Classes have the same issue, no no-go there as well.

irudnyts avatar Nov 15 '23 19:11 irudnyts