Search code examples
rmethodssignaturer-s4

Implementing rigorous overloaded method signatures that prevent misuse


I'm trying to implement different versions of a method that have similar but distinct input requirements. Coming from languages with static and strong typing, I'm trying to write these methods in such a way that the ability for target users (this is for a package) to use the methods in unintended ways is minimized. Different versions of the method have a different number of parameters, and I'm finding that supporting versions of the method with two or more parameter allows nonsense to be input into the version of the method that only expects one parameter. Here's a trivial example:

setGeneric(
  "foo",
  function(a, b) {
    standardGeneric("foo")
  })

setMethod(
  "foo",
  signature(a = "numeric"),
  function(a) {
    abs(a)
  })

setMethod(
  "foo",
  signature(a = "numeric", b = "numeric"),
  function(a, b) {
    abs(c(a, b))
  })

It works as expected with the following inputs (some are valid, some are not and throw errors like they should):

> foo(-1)
[1] 1

> foo(-1, -2)
[1] 1 2

> foo("cat")
 Error in (function (classes, fdef, mtable)  : 
  unable to find an inherited method for function ‘foo’ for signature ‘"character", "missing"’ 

> foo()
 Error in (function (classes, fdef, mtable)  : 
  unable to find an inherited method for function ‘foo’ for signature ‘"missing", "missing"’ 

> foo(-1, -2, "cat")
Error in foo(-1, -2, "cat") : unused argument ("cat")

But then there is one scenario where it doesn't behave in a manner that should be acceptable:

> foo(-1, "cat")
[1] 1

It's calling the first method signature and ignoring the second parameter. This is potentially a severe logic error for users, which is an issue for me because my target users are not computer scientists; most do not understand what logic errors are, how dangerous they are, or how to watch for them. Is there a way in R to set up the methods so that this last example foo(-1, "cat") throws an error rather than giving the user the appearance that all is well and good?

Note, that while the different versions of the methods I'm working on are fundamentally related, the actual implementations for each are very different. I could use functions with optional arguments, but that would require several checks to run entirely different large chunks of code. I was hoping to avoid this because it's not particularly clean or elegant.


Solution

  • You can get it to work if you use the special "missing" signature in the following manner:

    setGeneric(
      "foo",
      function(a, b) {
        standardGeneric("foo")
      })
    
    setMethod(
      "foo",
      signature(a = "numeric", b = "missing"),
      function(a, b) {
        abs(a)
      })
    
    setMethod(
      "foo",
      signature(a = "numeric", b = "numeric"),
      function(a, b) {
        abs(c(a, b))
      }
    )
    

    Checking calls:

    foo(-1)
    #[1] 1
    
    foo(-1, -2)
    #[1] 1 2
    
    foo(-1, "cat")
    #Error in (function (classes, fdef, mtable)  : 
    #  unable to find an inherited method for function ‘foo’ for
    #  signature ‘"numeric", "character"’ 
    
    foo("cat")
    # Error in (function (classes, fdef, mtable)  : 
    #  unable to find an inherited method for function ‘foo’ for
    #  signature ‘"character", "missing"’ 
    

    Likewise, foo() and foo(-1, -2, "cat") fails as before.

    As you have observed yourself, if you put in some print statements in your methods, you see that calling foo(-1, "cat") is dispatched to your one-argument method. The reason no error is thrown is, that the promise for the b argument is never needed and evaluated. I am not well-versed on the details of the S4 method dispatching rules in cases like this and whether or not it was expected. But in any case, in light of the "missing" signature, I guess it is good practice that arguments of the methods always match those of the generic; and I'm pretty sure something like R CMD check would complain if they do not match.