Go-Like Interfaces and Rust-Like Traits on S7
Source:vignettes/s7-interfaces-and-traits.Rmd
s7-interfaces-and-traits.RmdIntroduction
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 setosaThe 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] 5This 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 20A 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.66667This 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] TRUENow 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 TRUEThis 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
- The S7 package documentation: https://rconsortium.github.io/S7/.
- S7 issue #34, “Traits”: https://github.com/RConsortium/S7/issues/34.
- The Go specification, especially interface types: https://go.dev/ref/spec#Interface_types.
- Chewxy, “How To Use Go Interfaces”: https://blog.chewxy.com/2018/03/18/golang-interfaces/.
- The Rust book chapter on traits: https://doc.rust-lang.org/book/ch10-02-traits.html.
- The Rust reference chapter on traits: https://doc.rust-lang.org/reference/items/traits.html.
- The
lambda.rpackage on CRAN: https://cran.r-project.org/package=lambda.r.