Search code examples
rtidyversevctrs

How can I make a {vctrs} S3 class error on arithmetic with base S3 classes?


Working through 'Arithmetic' in vignette("s3-vector", "vctrs") to create my own class, I've hit some undesirable behaviour and I'm struggling to see how to address it. My issue is that arithmetic with my class and a base-R class actually works when I'd prefer it to throw an error. This behaviour manifests with the <vctrs_meter> class defined in the vignette, so I've used this class as a reprex below.

library(vctrs)
#> Warning: package 'vctrs' was built under R version 4.2.3

# -- Define a <vctrs_meter> class as per vignette ---------------------------------------------------------

meter <- function(x) {
  x <- vec_cast(x, double())
  new_meter(x)
}
new_meter <- function(x) {
  stopifnot(is.double(x))
  new_vctr(x, class = "vctrs_meter")
}
format.vctrs_meter <- function(x, ...) {
  paste0(format(vec_data(x)), " m")
}
vec_arith.vctrs_meter <- function(op, x, y, ...) {
  UseMethod("vec_arith.vctrs_meter", y)
}
vec_arith.vctrs_meter.default <- function(op, x, y, ...) {
  stop_incompatible_op(op, x, y)
}
vec_arith.vctrs_meter.vctrs_meter <- function(op, x, y, ...) {
  switch(
    op,
    "+" = ,
    "-" = new_meter(vec_arith_base(op, x, y)),
    "/" = vec_arith_base(op, x, y),
    stop_incompatible_op(op, x, y)
  )
}

# -- Expected behaviour with built-in types ---------------------------------------------------------------

# This works as expected
meter(10) + meter(1)
#> <vctrs_meter[1]>
#> [1] 11 m

# This doesn't work, also as expected
meter(10) + 1L
#> Error in `vec_arith()`:
#> ! <vctrs_meter> + <integer> is not permitted
#> Backtrace:
#>     ▆
#>  1. └─vctrs:::`+.vctrs_vctr`(meter(10), 1L)
#>  2.   ├─vctrs::vec_arith("+", e1, e2)
#>  3.   ├─global vec_arith.vctrs_meter("+", e1, e2)
#>  4.   └─global vec_arith.vctrs_meter.default("+", e1, e2)
#>  5.     └─vctrs::stop_incompatible_op(op, x, y)
#>  6.       └─vctrs:::stop_incompatible(...)
#>  7.         └─vctrs:::stop_vctrs(...)
#>  8.           └─rlang::abort(message, class = c(class, "vctrs_error"), ..., call = call)

# -- Unexpected behaviour with base S3 classes ------------------------------------------------------------

# These all work with warnings, whereas I'd expect an error
meter(1) + factor("hi")
#> Warning: Incompatible methods ("+.vctrs_vctr", "Ops.factor") for "+"
#> <vctrs_meter[1]>
#> [1] 2 m
meter(1) + Sys.Date()
#> Warning: Incompatible methods ("+.vctrs_vctr", "+.Date") for "+"
#> <vctrs_meter[1]>
#> [1] 19537 m
meter(1) + as.POSIXct(Sys.Date())
#> Warning: Incompatible methods ("+.vctrs_vctr", "+.POSIXt") for "+"
#> <vctrs_meter[1]>
#> [1] 1687910401 m

Created on 2023-06-28 with reprex v2.0.2

Ideally I'd like arithmetic with base S3 classes to fail in the same way as for built-in types as demonstrated in the reprex.

NB, I've created an issue on GitHub for this, as I think this behaviour is surprising enough to merit a change to the vignette.


Solution

  • The problem is that dispatch for the Ops group generics is more complicated than for most S3 generics. You can read all the details in ?Ops; here's the important extract:

    The classes of both arguments are considered in dispatching any member of this group. For each argument its vector of classes is examined to see if there is a matching specific (preferred) or Ops method. If a method is found for just one argument or the same method is found for both, it is used. If different methods are found, then the generic chooseOpsMethod() is called to pick the appropriate method. (See ?chooseOpsMethod for details). If chooseOpsMethod() does not resolve the method, then there is a warning about ‘incompatible methods’: in that case or if no method is found for either argument the internal method is used.

    The chooseOpsMethod() function mentioned here lets you prioritize one method over the other. You always want to prioritize your vctrs_meter class, so you should define

    chooseOpsMethod.vctrs_meter <- function(x, y, mx, my, cl, reverse) TRUE
    

    Now if there's an Ops method for the second argument, it will be ignored and the vctrs_meter method will be called, as you preferred.

    If there happens to be a chooseOpsMethod.<other class> method defined in the same way, then I think whichever one comes first will be called, i.e. meter(1) + factor("hi") would do what you want, but factor("hi") + meter(1) would call the factor method. As far as I know there are no base methods for chooseOpsMethod other than the default one.