Unexported S3 Methods and R Packages

August 13, 2020 at 9 PM

I’ve recently spent some time at work updating a few R packages we’ve built and deployed over the last several years, and during these updates I’ve run up against an old foe:

my_package::run_model(...)
#> Error in UseMethod("predict") : 
#>   no applicable method for 'predict' applied to an object of class "ranger"

In case you’re unfamiliar, this error stems from R’s S3 method-dispatch system: model is a “ranger” object, so R goes looking for a “ranger” version of the ‘predict’ method, but can’t find it—even though I have the ranger package installed and Imported. I’ve seen this occasionally ever since I stated working with R in 2016, but until now I’ve treated the symptoms. I usually patch around this error by forcing R to use the (private) function ranger:::predict.ranger() even though this is poor form, shouldn’t need to be done, and raises NOTEs during the R CMD check process.

I recently made some progress, though, by realizing that things ‘magically’ worked if I called, e.g., library(ranger) at the top of whatever script uses my package to get things done. And then, finally, I realized something else. One embedded call would work A-OK:

result <- ranger::predictions(predict(model, data))

while a separate call in another function would not:

result <- predict(model, data)

These two clues unlocked the solution.1 It turns out that, while Importing a package is necessary, it is not sufficient in order for its S3 methods to be made available during runtime. This includes methods like predict.<class>(). In fact, S3 methods aren’t registered at all unless you tell R to use some bit of the imported package earlier in your program.2

You can test this by calling methods(predict), which, in my example, will not list predict.ranger(). After calling any ranger function, however—say, ranger::predictions()—a subsequent call to methods(predict) does indeed list predict.ranger() as an available S3 method. This is why the ‘predict’ call wrapped in ranger::predictions worked for years, and I didn’t even notice; the :: call causes R to immediately load ranger along with its various S3 methods, so predict() dispatches just fine.

If the S3 method were exported from a package, I suppose one could simply import the predict.<class>() method directly, e.g., in roxygen2 documentation syntax:

#' @importFrom <package name> <method name>

But this doesn’t work if the S3 method isn’t exported—and a ‘predict’ method shouldn’t really need to be.

With the problem better understood, my solution is to do one of two things:

  1. Call, or import, another function from the package (e.g., ranger::predictions()). This alone should cause the package to attach its namespace and register S3 methods when my package is loaded.
  2. Add a single requireNamespace(<package name>, quietly = TRUE) call to the top of the function of interest, or to my package’s .onLoad() function. Unlike library(), this causes R to register the appropriate S3 methods, etc., but prevents the package from “attaching”, from adding itself to the search path so that all its functions are globally available.3 You can confirm this again by checking methods(predict) before and after calling ‘requireNamespace’, including for non-exported S3 methods like predict.ranger().

Live and learn, I guess. 🤷🏻‍♂️


  1. I’ve also written up an answer on StackOverflow↩︎

  2. There’s probably a good reason for this, but I cant’t say that I like it. ↩︎

  3. Note that once the S3 methods are registered there seems to be no good way to deregister them↩︎