Skip to contents

Introduction

s7contract is a small experiment: can interface and trait ideas be expressed on top of S7 without replacing S7’s method system? The package answers yes, but with an important constraint. S7 still owns dispatch. s7contract only records and checks contracts around ordinary S7 generics.

The most natural layer is a Go-like structural interface. In S7, operations are already ordinary functions such as draw(x) or area(x), and methods are registered for classes. A structural interface can therefore be just a named set of required generics.

A Rust-like trait is also possible, but it needs an explicit registry. That extra machinery is useful for default methods and associated metadata, but it is less native to S7 because R does not have Rust’s compile-time trait bounds, coherence rules, or associated type checker.

Background

S7 is a functional object-oriented system: methods belong to generic functions, not to objects. The call is generic(object, ...), not object$generic(...). That makes S7 close in spirit to protocols defined by behavior.

Go interfaces are structural: a basic interface describes required methods, and a type satisfies the interface when it has those methods. Go style also favors small interfaces defined at the point of use: do not define an interface beside a single implementation merely to make that implementation conform. In S7 terms, define classes, generics, and methods where the behavior lives, then let the consumer define the protocol it accepts. Rust traits are nominal and explicit: an implementation is declared for a type, and traits may also contain defaults and associated items. s7contract maps these ideas to S7 as follows.

S7 generic       operation, e.g. area(x)
S7 method        implementation for a class
Go-like interface set of required S7 generics
Rust-like trait  explicit implementation record plus S7 methods

This also clarifies what kind of “type” an interface defines. An S7 class is a nominal representation type: it says how an object is constructed and validated. An interface is a behavioral or protocol type: it says what operations must be available. Both are useful, but they answer different questions.

Packages such as lambda.r explore a different functional-programming route in R, with pattern-matching-style function clauses, guards, and optional type constraints. s7contract stays closer to S7: it does not create a new function clause language. By default it checks whether S7 can find methods for required generics, or whether an explicit trait implementation has been registered. Optional argument and return specifications can be checked when evaluating a call with with() or %::%.

A structural interface

The classic drawing example remains useful because the behavior is visible. A Drawable object is anything for which S7 can find a draw() method. In real packages, this interface would usually live near the consumer that needs to draw things, not necessarily in the package that defines Circle.

area <- new_generic("area", "x")
draw <- new_generic("draw", "x")

Circle <- new_class("Circle", properties = list(r = class_double))
Rect <- new_class("Rect", properties = list(w = class_double, h = class_double))

method(area, Circle) <- function(x) pi * x@r^2
method(draw, Circle) <- function(x) sprintf("circle(r = %s)", x@r)
method(area, Rect) <- function(x) x@w * x@h

Drawable <- new_interface("Drawable", generics = list(draw = draw))
Shape <- new_interface("Shape", generics = list(area = area), parents = Drawable)

implements(Circle, Shape)
#> [1] TRUE
implements(Rect, Shape)
#> [1] FALSE
missing_requirements(Rect, Shape)
#>      interface requirement    ok                               message
#> draw     Shape        draw FALSE Can't find method for `draw(<Rect>)`.

A consumer keeps ordinary S7 style. The assertion documents the expected behavior; the actual call is still normal dispatch through draw(x).

render <- function(x) {
  assert_implements(x, Drawable)
  draw(x)
}

render(Circle(r = 2))
#> [1] "circle(r = 2)"

The same boundary is convenient in tests. A mock only needs the behavior the consumer asks for.

MockDrawable <- new_class("MockDrawable")
method(draw, MockDrawable) <- function(x) "mock drawing"

render(MockDrawable())
#> [1] "mock drawing"

This is the main reason structural interfaces fit S7 well. They add a small runtime check around a dispatch model that S7 already has. The practical API shape is the Go maxim adapted to R: accept objects that satisfy a small protocol; return ordinary, concrete R or S7 values.

When the whole protocol is a class family

Some R APIs intentionally define a large protocol up front. DBI is the useful example: it is not just one consumer-local interface, but a package-level standard built around nominal connection, driver, and result classes plus many generic functions. That fits the “abstract data type” exception to the point-of-use rule.

In S7, the analogous design is a class family for identity and representation, plus generics for behavior. Consumers can still depend on a smaller interface when they only need part of the protocol.

DatabaseConnection <- new_class("DatabaseConnection", abstract = TRUE)
MemoryConnection <- new_class(
  "MemoryConnection",
  parent = DatabaseConnection,
  properties = list(tables = class_list)
)

db_tables <- new_generic("db_tables", "con")
db_read_table <- new_generic(
  "db_read_table",
  "con",
  function(con, name) S7_dispatch()
)

method(db_tables, MemoryConnection) <- function(con) names(con@tables)
method(db_read_table, MemoryConnection) <- function(con, name) con@tables[[name]]

TableReader <- new_interface(
  "TableReader",
  generics = list(
    db_tables = interface_requirement(db_tables, returns = class_character),
    db_read_table = interface_requirement(
      db_read_table,
      args = list(name = class_character),
      returns = class_data.frame
    )
  )
)

first_table <- function(con) {
  assert_implements(con, TableReader)
  db_read_table(con, db_tables(con)[[1]])
}

con <- MemoryConnection(tables = list(iris = head(iris, 2)))
first_table(con)
#>   Sepal.Length Sepal.Width Petal.Length Petal.Width Species
#> 1          5.1         3.5          1.4         0.2  setosa
#> 2          4.9         3.0          1.4         0.2  setosa

The class says “this object is a database connection”. The interface says “this consumer needs table-reading behavior”. A full DBI-like package may own the broad class family; ordinary downstream functions should still prefer the smallest protocol they use.

Progressive argument and return checks

Interface requirements can optionally carry argument and return specifications. The default is permissive: unspecified arguments are not checked, and the return specification defaults to S7::class_any. When specifications are present, expressions can be evaluated in a contract mask with either with() or the lambda.r-style %::% operator. Calls to required generics inside that expression are checked.

Canvas <- new_class("Canvas")

draw_on <- new_generic(
  "draw_on",
  c("x", "canvas"),
  function(x, canvas, position, ...) S7_dispatch()
)

method(draw_on, list(Circle, Canvas)) <- function(x, canvas, position, ...) {
  sprintf("circle(r = %s) at %s", x@r, position)
}

DrawableOnCanvas <- new_interface(
  "DrawableOnCanvas",
  generics = list(
    draw_on = interface_requirement(
      draw_on,
      args = list(canvas = Canvas, position = class_integer),
      returns = class_character
    )
  )
)

canvas <- Canvas()
circle <- Circle(r = 2)

implements(Circle, DrawableOnCanvas)
#> [1] TRUE
with(DrawableOnCanvas, draw_on(circle, canvas, position = 1L))
#> [1] "circle(r = 2) at 1"
draw_on(circle, canvas, position = 1L) %::% DrawableOnCanvas
#> [1] "circle(r = 2) at 1"

checked_draw <- with(DrawableOnCanvas, {
  function(x) draw_on(x, canvas, position = 1L)
})
checked_draw(circle)
#> [1] "circle(r = 2) at 1"

A method can satisfy the S7 method shape but still return the wrong kind of value. The checked call, including a function returned from with(), catches that after ordinary S7 dispatch has run.

BadCircle <- new_class("BadCircle", properties = list(r = class_double))
method(draw_on, list(BadCircle, Canvas)) <- function(x, canvas, position, ...) {
  x@r
}

tryCatch(
  with(DrawableOnCanvas, draw_on(BadCircle(r = 2), canvas, position = 1L)),
  error = function(e) conditionMessage(e)
)
#> [1] "Return value must be <character>, not <double>"

tryCatch(
  checked_draw(BadCircle(r = 2)),
  error = function(e) conditionMessage(e)
)
#> [1] "Return value must be <character>, not <double>"

The input checks use S7 classes and S7 multiple dispatch. In this example, canvas is also a dispatch argument, so implements(Circle, DrawableOnCanvas) asks S7 for a draw_on(<Circle>, <Canvas>) method. The return value can only be checked after the call has run, which is why with() and %::% are useful.

Number-like behavior

A general Number interface is tempting, but it should be treated carefully. Base R arithmetic includes vectorization, recycling, missing values, attributes, and binary operations. A small number-like protocol is more honest: it says only which operations a particular consumer needs.

num_zero <- new_generic("num_zero", "x")
num_add <- new_generic("num_add", "x")
num_scale <- new_generic("num_scale", "x")

NumberLike <- new_interface(
  "NumberLike",
  generics = list(
    zero = num_zero,
    add = num_add,
    scale = num_scale
  )
)

method(num_zero, class_double) <- function(x) 0
method(num_add, class_double) <- function(x, y) x + y
method(num_scale, class_double) <- function(x, k) x * k

implements(class_double, NumberLike)
#> [1] TRUE
num_add(10, 5)
#> [1] 15
num_scale(10, 0.5)
#> [1] 5

This interface does not prove mathematical laws such as associativity or an identity element. It only says that these operations are present. If those laws matter, they should be described in the documentation and tested with examples that are specific to the domain.

Vector-like behavior

A vector-like contract is often more practical. Many algorithms only need a length, a way to slice, and a way to expose values.

vec_length <- new_generic("vec_length", "x")
vec_slice <- new_generic("vec_slice", "x")
vec_values <- new_generic("vec_values", "x")

VectorLike <- new_interface(
  "VectorLike",
  generics = list(
    length = vec_length,
    slice = vec_slice,
    values = vec_values
  )
)

ReadDepth <- new_class(
  "ReadDepth",
  properties = list(
    position = class_integer,
    depth = class_double
  ),
  validator = function(self) {
    if (length(self@position) != length(self@depth)) {
      "@position and @depth must have the same length"
    }
  }
)

method(vec_length, ReadDepth) <- function(x) length(x@depth)
method(vec_slice, ReadDepth) <- function(x, i) {
  ReadDepth(position = x@position[i], depth = x@depth[i])
}
method(vec_values, ReadDepth) <- function(x) x@depth

coverage <- ReadDepth(
  position = 1:5,
  depth = c(12, 15, 9, 20, 17)
)

implements(coverage, VectorLike)
#> [1] TRUE
vec_values(vec_slice(coverage, 2:4))
#> [1] 15  9 20

A function can depend on this small protocol without knowing how the object is represented internally.

window_mean <- function(x, i) {
  assert_implements(x, VectorLike)
  mean(vec_values(vec_slice(x, i)))
}

window_mean(coverage, 2:4)
#> [1] 14.66667

This kind of interface is best used at package boundaries. It is not meant to replace base vectors, S7 classes, or mature vector frameworks; it names the small piece of behavior a consumer needs.

An explicit trait

A Rust-like trait adds nominal intent. A class may have the right methods structurally, but it does not have the trait until impl_trait() records that implementation.

perimeter <- new_generic("perimeter", "x")

Measurable <- new_trait(
  "Measurable",
  methods = list(
    area = trait_method(area),
    perimeter = trait_method(perimeter, default = function(x) NA_real_)
  ),
  assoc_consts = c("UNITS")
)

impl_trait(
  Measurable,
  Circle,
  methods = list(area = function(x) pi * x@r^2),
  assoc_consts = list(UNITS = "unitless"),
  replace = TRUE
)
#> Overwriting method area(<Circle>)

has_trait(Circle, Measurable)
#> [1] TRUE
trait_call(Measurable, "area", Circle(r = 2))
#> [1] 12.56637
trait_call(Measurable, "perimeter", Circle(r = 2))
#> [1] NA
trait_assoc_const(Measurable, Circle, "UNITS")
#> [1] "unitless"

The useful distinction is intent. A structural interface asks whether operations are available. An explicit trait asks whether a package author has declared a class to implement a named contract. The trait layer can also store associated metadata such as UNITS, which is awkward in a purely structural interface.

A Haskell-style dictionary object

Haskell type classes are often explained as dictionaries: a Monad m constraint is operationally evidence that m has pure and bind. R can model that idea directly because functions are first-class values and S7 can validate function-valued properties.

The example below defines a tiny Maybe algebraic data type, then stores its monad operations in an S7 dictionary object. The s7contract interface checks that the dictionary exposes the operations a consumer expects.

Maybe <- new_class("Maybe", abstract = TRUE)
Nothing <- new_class("Nothing", parent = Maybe)
Just <- new_class("Just", parent = Maybe, properties = list(value = class_any))

MonadDict <- new_class(
  "MonadDict",
  properties = list(
    name = class_character,
    pure = class_function,
    bind = class_function
  )
)

dict_pure <- new_generic("dict_pure", "x")
dict_bind <- new_generic("dict_bind", "x")

MonadDictionary <- new_interface(
  "MonadDictionary",
  generics = list(
    pure = dict_pure,
    bind = dict_bind
  )
)

method(dict_pure, MonadDict) <- function(x, value) {
  (x@pure)(value)
}
method(dict_bind, MonadDict) <- function(x, mx, f) {
  (x@bind)(mx, f)
}

MaybeMonad <- MonadDict(
  name = "Maybe",
  pure = function(value) Just(value = value),
  bind = function(mx, f) {
    if (S7_inherits(mx, Nothing)) {
      Nothing()
    } else {
      f(mx@value)
    }
  }
)

implements(MaybeMonad, MonadDictionary)
#> [1] TRUE

Now the dictionary can be passed around as an ordinary R object.

dict_bind(
  MaybeMonad,
  Just(value = 2),
  function(x) dict_pure(MaybeMonad, x + 1)
)
#> <Just>
#>  @ value: num 3

dict_bind(
  MaybeMonad,
  Nothing(),
  function(x) dict_pure(MaybeMonad, x + 1)
)
#> <Nothing>

The interface checks operation availability. The monad laws are semantic properties, so they belong in tests. A small law check can still be written in plain R.

maybe_equal <- function(x, y) {
  if (S7_inherits(x, Nothing) && S7_inherits(y, Nothing)) {
    return(TRUE)
  }
  if (S7_inherits(x, Just) && S7_inherits(y, Just)) {
    return(identical(x@value, y@value))
  }
  FALSE
}

f <- function(x) dict_pure(MaybeMonad, x + 1)
g <- function(x) dict_pure(MaybeMonad, x * 2)
mx <- Just(value = 10)

c(
  left_identity = maybe_equal(
    dict_bind(MaybeMonad, dict_pure(MaybeMonad, 10), f),
    f(10)
  ),
  right_identity = maybe_equal(
    dict_bind(MaybeMonad, mx, function(x) dict_pure(MaybeMonad, x)),
    mx
  ),
  associativity = maybe_equal(
    dict_bind(MaybeMonad, dict_bind(MaybeMonad, mx, f), g),
    dict_bind(MaybeMonad, mx, function(x) dict_bind(MaybeMonad, f(x), g))
  )
)
#>  left_identity right_identity  associativity 
#>           TRUE           TRUE           TRUE

This is not Haskell’s static kind system. It is a concrete R/S7 encoding of the same operational idea: a type-class instance can be represented as a runtime object containing functions, and an interface can state which functions must be available.

Which feels more natural?

For functional OOP in S7, Go-like structural interfaces are the default fit. S7 already makes generic functions the center of dispatch, so an interface as a set of required generics is small and idiomatic.

Rust-like traits are heavier but useful when accidental compatibility would be a problem. They make sense for plugin systems, adapters, or domain protocols where a package should explicitly claim conformance and provide metadata or defaults.

The practical rule is simple: start with a structural interface when the consumer only needs behavior; use a trait when the declaration itself carries meaning.

References