rr-s3

R generic dispatching to attached environment


I have a bunch of functions and I'm trying to keep my workspace clean by defining them in an environment and attaching the environment. Some of the functions are S3 generics, and they don't seem to play well with this approach.

A minimum example of what I'm experiencing requires 4 files:

testfun.R

ttt.xxx <- function(object) print("x")

ttt <- function(object) UseMethod("ttt")

ttt2 <- function() {
  yyy <- structure(1, class="xxx")
  ttt(yyy)
}

In testfun.R I define an S3 generic ttt and a method ttt.xxx, I also define a function ttt2 calling the generic.

testenv.R

test_env <- new.env(parent=globalenv()) 
source("testfun.R", local=test_env) 
attach(test_env)

In testenv.R I source testfun.R to an environment, which I attach.

test1.R

source("testfun.R")
ttt2()
xxx <- structure(1, class="xxx")
ttt(xxx)

test1.R sources testfun.R to the global environment. Both ttt2 and a direct function call work.

test2.R

source("testenv.R")
ttt2()
xxx <- structure(1, class="xxx")
ttt(xxx)

test2.R uses the "attach" approach. ttt2 still works (and prints "x" to the console), but the direct function call fails:

Error in UseMethod("ttt") : 
  no applicable method for 'ttt' applied to an object of class "xxx"

however, calling ttt and ttt.xxx without arguments show that they are known, ls(pos=2) shows they are on the search path, and sloop::s3_dispatch(ttt(xxx)) tells me it should work.

This questions is related to Confusion about UseMethod search mechanism and the link therein https://blog.thatbuthow.com/how-r-searches-and-finds-stuff/, but I cannot get my head around what is going on: why is it not working and how can I get this to work.

I've tried both R Studio and R in the shell.

UPDATE: Based on the answers below I changed my testenv.R to:

test_env <- new.env(parent=globalenv()) 
source("testfun.R", local=test_env) 
attach(test_env)
if (is.null(.__S3MethodsTable__.))
  .__S3MethodsTable__. <- new.env(parent = baseenv())
for (func in grep(".", ls(envir = test_env), fixed = TRUE, value = TRUE))
  .__S3MethodsTable__.[[func]] <- test_env[[func]]
rm(test_env, func)

... and this works (I am only using "." as an S3 dispatching separator).


Solution

  • It’s a little-known fact that you must use .S3method() to define methods for S3 generics inside custom environments (outside of packages).1 The reason almost nobody knows this is because it is not necessary in the global environment; but it is necessary everywhere else since R version 3.6.

    There’s virtually no documentation of this change, just a technical blog post by Kurt Hornik about some of the background. Note that the blog post says the change was made in R 3.5.0; however, the actual effect you are observing — that S3 methods are no longer searched in attached environments — only started happening with R 3.6.0; before that, it was somehow not active yet.

    … except just using .S3method will not fix your code, since your calling environment is the global environment. I do not understand the precise reason why this doesn’t work, and I suspect it’s due to a subtle bug in R’s S3 method lookup. In fact, using getS3method('ttt', 'xxx') does work, even though that should have the same behaviour as actual S3 method lookup.

    I have found that the only way to make this work is to add the following to testenv.R:

    if (is.null(.__S3MethodsTable__.)) {
        .__S3MethodsTable__. <- new.env(parent = baseenv())
    }
    
    .__S3MethodsTable__.$ttt.xxx <- ttt.xxx
    

    … in other words: supply .GlobalEnv manually with an S3 methods lookup table. Unfortunately this relies on an undocumented S3 implementation detail that might theoretically change in the future.

    Alternatively, it “just works” if you use ‘box’ modules instead of source. That is, you can replace the entirety of your testenv.R by the following:

    box::use(./testfun[...])
    

    This code treats testfun.R as a local module and loads it, attaching all exported names (via the attach declaration [...]).


    1 (and inside packages you need to use the equivalent S3method namespace declaration, though if you’re using ‘roxygen2’ then that’s taken care of for you)