Class Systems in miniextendr
miniextendr supports five R class systems. This guide helps you choose the right one for your use case.
miniextendr supports five R class systems. This guide helps you choose the right one for your use case.
πQuick Comparison
| Feature | Env | R6 | S3 | S4 | S7 |
|---|---|---|---|---|---|
| Attribute | #[miniextendr] | #[miniextendr(r6)] | #[miniextendr(s3)] | #[miniextendr(s4)] | #[miniextendr(s7)] |
| Method Call | obj$method() | obj$method() | generic(obj) | generic(obj) | generic(obj) |
| Encapsulation | Weak | Strong | None | Moderate | Strong |
| Dependencies | None | R6 package | None | methods package | S7 package |
| Active Bindings | No | Yes | No | No | Yes (computed/dynamic properties) |
| Inheritance | No | Limited | S3 dispatch | S4 dispatch | S7 dispatch |
| Best For | Simple APIs | Complex state | Tidyverse compat | Bioconductor | Modern OOP |
πChoosing a Class System
βββββββββββββββββββββββββββββββββββββββ
β Do you need method dispatch on β
β object type (polymorphism)? β
βββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββ΄βββββββββββββββββββββ
β No β Yes
βΌ βΌ
ββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
β Use Env style β β Do you need tidyverse β
β (simplest) β β compatibility? β
ββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββ΄βββββββββββββββββββββ
β Yes β No
βΌ βΌ
βββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
β Use S3 β β Need reference semantics β
β (generic.class) β β (modify in place)? β
βββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββ΄βββββββββββββββββββββ
β Yes β No
βΌ βΌ
βββββββββββββββββββββ βββββββββββββββββββββββββ
β Use R6 β β Modern or legacy? β
β (encapsulation) β β β
βββββββββββββββββββββ βββββββββββββββββββββββββ
β
βββββββββββββββββββββββββ΄ββββββββββββββββ
β Modern β Legacy
βΌ βΌ
βββββββββββββββββββββ βββββββββββββββββββββ
β Use S7 β β Use S4 β
β (new standard) β β (Bioconductor) β
βββββββββββββββββββββ βββββββββββββββββββββ
πEnvironment Style (Default)
The simplest approach. Methods are functions attached to an environment.
πRust Code
#[derive(miniextendr_api::ExternalPtr)]
pub struct Counter {
value: i32,
}
#[miniextendr] // env is default
impl Counter {
/// Create a new counter.
pub fn new(initial: i32) -> Self {
Counter { value: initial }
}
/// Get the current value.
pub fn value(&self) -> i32 {
self.value
}
/// Increment by one.
pub fn inc(&mut self) {
self.value += 1;
}
}πGenerated R Code
The .Call() symbols are prefixed with your crateβs name (mypkg in these
examples) so that two packages loaded into the same webR session can never
collide on a C symbol (see docs/WEBR.md). The prefix is invisible to package
users β R-facing function and class names are unchanged.
Counter <- new.env(parent = emptyenv())
Counter$new <- function(initial) {
if (!isTRUE(is.integer(initial))) .miniextendr_arg_error("initial", "must be integer")
if (!isTRUE(length(initial) == 1L)) .miniextendr_arg_error("initial", "must have length 1")
.val <- .Call(C_mypkg_Counter__new, .call = sys.call(), initial)
if (inherits(.val, "rust_condition_value") && isTRUE(attr(.val, "__rust_condition__"))) return(.miniextendr_raise_condition(.val, sys.call()))
self <- .val
class(self) <- "Counter"
self
}
Counter$value <- function() {
.val <- .Call(C_mypkg_Counter__value, .call = sys.call(), .mx_self)
if (inherits(.val, "rust_condition_value") && isTRUE(attr(.val, "__rust_condition__"))) return(.miniextendr_raise_condition(.val, sys.call()))
.val
}
Counter$inc <- function() {
.val <- .Call(C_mypkg_Counter__inc, .call = sys.call(), .mx_self)
if (inherits(.val, "rust_condition_value") && isTRUE(attr(.val, "__rust_condition__"))) return(.miniextendr_raise_condition(.val, sys.call()))
.mx_self
}
`$.Counter` <- function(self, name) {
.miniextendr_env_method(self, name, Counter)
}
`[[.Counter` <- function(self, name, ..., exact = TRUE) {
if (...length()) .miniextendr_check_dots(...names(), ...length(), c("self", "name", "exact"), call = environment())
.miniextendr_env_method(self, name, Counter)
}
The object is the external pointer: the constructor takes the EXTPTRSXP
returned by .Call(), sets its class attribute, and returns it. There is no
list and no .ptr field. The methods live in the Counter environment and
take no receiver formal; each body refers to .mx_self. No Rust parameter
can produce a dotted R name, so a method may take arguments named self or
x. An R formal is the Rust parameter name without leading underscores, so
_self: i32 becomes self; clippyβs duplicate_underscore_argument lint
flags _self beside &self, so allow it where you use one. .mx_self resolves
through the $.Counter dispatcher: c$value() calls $.Counter(c, "value"),
which calls .miniextendr_env_method, a helper the wrapper file defines (its
source is in miniextendr-api/src/registry.rs). The helper fetches
Counter$value and returns it with a new enclosing environment that holds
only .mx_self. That environmentβs parent is the methodβs original environment,
the package namespace, so package objects used by method bodies and parameter
defaults keep resolving there. The method stored in Counter is unchanged, and each
returned closure keeps its own receiver alive. [[ does the same lookup. It
also takes exact, as a listβs [[ does, and ignores it, since names
match exactly; any other argument is refused as under dots = empty
(obj[["value", 2]] is unused argument '..1' (unnamed)). A &mut self
method returns .mx_self, so calls
chain. Errors raised from Rust come back as a tagged value and are re-raised by
.miniextendr_raise_condition with the call attributed to the userβs frame
(see docs/CALL_ATTRIBUTION.md).
An Env objectβs flat trait lookup (obj$method()) requires one matching
trait. If several traits provide the method, it raises
miniextendr_ambiguous_method with the method name in condition$method and
the matching trait names in condition$traits, reporting the call as written
(obj$method). Select the trait explicitly
with obj$Trait$method(). An inherent method of that name takes precedence.
The same rule applies to trait static methods and associated constants.
πUsage
c <- Counter$new(0L)
c$value() # 0
c$inc() # returns the object, so c$inc()$inc() also works
c$value() # 1
c[["value"]]() # same dispatch as `$`
Trait methods implemented for an env class sit in a namespace named after the
trait: c$MyTrait$method() binds the receiver for you, and
Counter$MyTrait$method(c) is the standalone form. Its receiver formal is
.mx_self, as for R6 trait methods (Type$Trait$method(obj, ...)), so trait
methods may take arguments named x, self or private; pass the object by
position. Calling an inherent method without an object (Counter$value())
fails with object '.mx_self' not found, because nothing bound .mx_self.
πWhen to Use
- Simple APIs with few methods
- No need for method dispatch
- Minimal dependencies
- Quick prototyping
πR6 Style
Full-featured reference classes with encapsulation.
πRust Code
#[derive(miniextendr_api::ExternalPtr)]
pub struct Rectangle {
width: f64,
height: f64,
}
#[miniextendr(r6)]
impl Rectangle {
pub fn new(width: f64, height: f64) -> Self {
Rectangle { width, height }
}
pub fn get_width(&self) -> f64 {
self.width
}
pub fn set_width(&mut self, width: f64) {
self.width = width;
}
/// Active binding for computed property.
#[miniextendr(r6(active))]
pub fn area(&self) -> f64 {
self.width * self.height
}
/// Private method.
fn validate(&self) -> bool {
self.width > 0.0 && self.height > 0.0
}
/// Static method.
pub fn square(size: f64) -> Self {
Rectangle { width: size, height: size }
}
}πGenerated R Code
Rectangle <- R6::R6Class("Rectangle",
public = list(
initialize = function(width, height, .ptr = NULL) {
if (!is.null(.ptr)) {
private$.ptr <- .ptr
} else {
private$.ptr <- .Call(C_mypkg_Rectangle__new, width, height)
}
},
get_width = function() {
.Call(C_mypkg_Rectangle__get_width, private$.ptr)
},
set_width = function(width) {
.Call(C_mypkg_Rectangle__set_width, private$.ptr, width)
}
),
private = list(
.ptr = NULL,
validate = function() {
.Call(C_mypkg_Rectangle__validate, private$.ptr)
}
),
active = list(
area = function() {
.Call(C_mypkg_Rectangle__area, private$.ptr)
}
),
lock_objects = TRUE,
lock_class = FALSE,
cloneable = FALSE
)
# Static method
Rectangle$square <- function(size) {
Rectangle$new(.ptr = .Call(C_mypkg_Rectangle__square, size))
}πUsage
r <- Rectangle$new(3, 4)
r$get_width() # 3
r$area # 12 (active binding, no parens!)
r$set_width(5)
r$area # 20
# Static method
s <- Rectangle$square(5)
s$area # 25πWhen to Use
- Complex state management
- Need private methods
- Active bindings (computed properties)
- Reference semantics (modify in place)
πExtending a generated R6 class from R
When you need to add R-only methods to a miniextendr-scaffolded R6 class (e.g. convenience wrappers, formatting helpers, or methods that delegate to other R packages), roxygen2 8.0.0 lets you document those additions outside the generated class body.
Primary form β place a roxygen block directly above a $set() call; roxygen2
auto-associates it with the class:
#' @description Return a formatted label for the object.
#' @return A character string.
Rectangle$set("public", "label", function() {
paste0("Rectangle(", self$get_width(), " x ", private$.height, ")")
})
Fallback form β when the $set() call escapes roxygen2βs source-tracing (e.g. it lives
inside a helper function or a conditional block), attach a @R6method tag to a bare NULL:
#' @R6method Rectangle$label
#' @description Return a formatted label for the object.
#' @return A character string.
NULL
Rectangle$set("public", "label", function() {
paste0("Rectangle(", self$get_width(), " x ", private$.height, ")")
})
The @R6method ClassName$method_name tag tells roxygen2 where to anchor the documentation
even when it cannot trace the code attachment itself.
πField Access via Sidecar
For R6 classes, #[r_data] sidecar fields become active bindings with
r6(r_data_accessors):
#[derive(ExternalPtr)]
pub struct MyStruct {
#[r_data]
_r: RSidecar,
#[r_data]
pub name: String,
#[r_data]
pub value: f64,
}
#[miniextendr(r6(r_data_accessors))]
impl MyStruct {
pub fn new(name: String, value: f64) -> Self {
MyStruct { _r: RSidecar, name, value }
}
}
This generates obj$name and obj$value active bindings, with setters
(obj$name <- "new"). See βDirect Field Access via Sidecarβ below.
πS3 Style
Traditional R generic function dispatch.
πRust Code
#[derive(miniextendr_api::ExternalPtr)]
pub struct Person {
name: String,
age: i32,
}
#[miniextendr(s3)]
impl Person {
pub fn new(name: String, age: i32) -> Self {
Person { name, age }
}
/// Implements print.Person - `&mut self` hands back `x`; the
/// `Invisible<()>` marker makes that `invisible(x)` (#1213)
#[miniextendr(s3(generic = "print"))]
pub fn show(&mut self) -> Invisible<()> {
println!("Person: {}, age {}", self.name, self.age);
Invisible(())
}
/// Implements format.Person
#[miniextendr(s3(generic = "format"))]
pub fn fmt(&self) -> String {
format!("{} ({})", self.name, self.age)
}
pub fn greet(&self) -> String {
format!("Hello, I'm {}!", self.name)
}
}πGenerated R Code
#' @export
new_person <- function(name, age) {
ptr <- .Call(C_mypkg_Person__new, name, age)
structure(ptr, class = "Person")
}
#' @export
print.Person <- function(x, ...) {
.Call(C_mypkg_Person__show, x)
invisible(x)
}
#' @export
format.Person <- function(x, ...) {
.Call(C_mypkg_Person__fmt, x)
}
#' @export
greet <- function(x, ...) UseMethod("greet")
#' @export
greet.Person <- function(x, ...) {
.Call(C_mypkg_Person__greet, x)
}πUsage
p <- new_person("Alice", 30)
print(p) # Person: Alice, age 30
format(p) # "Alice (30)"
greet(p) # "Hello, I'm Alice!"
# Works with tidyverse
tibble::tibble(person = list(p))πBase non-generic collisions
A method name whose only existing binding is a plain (non-generic) base or
stats closure β var, get, row, col, diag, reshape, β¦ β is
classified and shadowed automatically (#1248): the generated code checks
utils::isS3stdGeneric() / methods::isGeneric() / is.primitive() on the
existing binding, and if none apply, defines a package-local UseMethod
generic in its place. Ordinary (non-dispatching) calls to the masked
function, e.g. var(1:10), keep working β a default method wrapping the
original closure is registered via base::registerS3method(), so it lives
only in the namespaceβs S3 methods table (never as a name.default
namespace binding, which would trip roxygen2βs dynamic S3-export scan).
This mirrors the S7 class systemβs #1114 classifier.
utils::isS3stdGeneric() / methods::isGeneric() are called unconditionally
by every generated S3 (and vctrs) generic guard, so packages using the S3 or
vctrs class systems must import methods and utils:
minirextendr::use_s3() (or use_vctrs()) declares both.
πWhen to Use
- Tidyverse integration
- Extending existing generics (print, format, etc.)
- vctrs-compatible types
- Simple polymorphism
πS4 Style
Formal class system with slots and method signatures.
πRust Code
#[derive(miniextendr_api::ExternalPtr)]
pub struct Gene {
symbol: String,
chromosome: i32,
}
#[miniextendr(s4)]
impl Gene {
pub fn new(symbol: String, chromosome: i32) -> Self {
Gene { symbol, chromosome }
}
pub fn symbol(&self) -> String {
self.symbol.clone()
}
pub fn chromosome(&self) -> i32 {
self.chromosome
}
/// `Invisible<()>`: a void method returns the object, and `show()` at
/// the console would print it again (#1213)
#[miniextendr(s4(generic = "show", receiver = "object", no_dots))]
pub fn display(&self) -> Invisible<()> {
println!("Gene {} on chr{}", self.symbol, self.chromosome);
Invisible(())
}
}πGenerated R Code
setClass("Gene", contains = "externalptr")
#' @export
Gene <- function(symbol, chromosome) {
ptr <- .Call(C_mypkg_Gene__new, symbol, chromosome)
new("Gene", ptr)
}
setGeneric("symbol", function(object) standardGeneric("symbol"))
setMethod("symbol", "Gene", function(object) {
.Call(C_mypkg_Gene__symbol, object)
})
setGeneric("chromosome", function(object) standardGeneric("chromosome"))
setMethod("chromosome", "Gene", function(object) {
.Call(C_mypkg_Gene__chromosome, object)
})
setMethod("show", "Gene", function(object) {
.Call(C_mypkg_Gene__display, object)
})πUsage
g <- Gene("TP53", 17L)
symbol(g) # "TP53"
chromosome(g) # 17
show(g) # Gene TP53 on chr17πMethods on an existing generic (S3 and S4)
s3(generic = "...") and s4(generic = "...") add a method to a generic that
already exists: baseβs print, format or summary, methods::show, the
primitive length, or a generic of an imported package. An S3 generic of a
suggested package is named with its package, s3(generic = "dplyr::dplyr_reconstruct"),
and the method is registered when that package loads
(S3_METHODS.md). The methodβs receiver
formal is x; when the generic names its first argument differently, say so
with receiver, and drop the ... the wrapper otherwise appends with
no_dots when the generic has none:
#[miniextendr(s3(generic = "summary", receiver = "object"))]
pub fn summarise(&self) -> String { ... } // summary.Gene <- function(object, ...)
#[miniextendr(s4(generic = "show", receiver = "object", no_dots))]
pub fn display(&self) -> Invisible<()> { ... } // setMethod("show", c(object = "Gene"), function(object) ...)
The receiver is what the wrapper reads the pointer from and what a void or
&mut self method hands back. The genericβs other formals are ordinary Rust
parameters (all.equal(target, current, ...) is receiver = "target" plus a
current parameter); a parameter whose R name is the receiver is a compile
error. A &Dots parameter still supplies ... under no_dots. receiver and
no_dots need generic and an instance method; trait impl methods accept all
three.
An explicit S3 generic never defines a generic. An explicit S4 generic is
defined only when no generic or function of that name is visible from the
package, so the method attaches to show or length instead of masking them
with a generic of the packageβs own. A generic from an installed copy of the
same package does not count, so load_all() with that copy attached still
defines it. A name nothing defines is created as
setGeneric(name, function(<receiver>, ...)), but gets no generated help page
(#2039); without generic, an S4 method uses the documented
s4_<method> generic.
πWhen to Use
- Bioconductor packages
- Formal class hierarchies
- Strict type checking
- Legacy S4 codebases
πS7 Style
Modern OOP system (successor to S3/S4).
πRust Code
use miniextendr_api::ExternalPtr;
#[derive(miniextendr_api::ExternalPtr)]
pub struct Point {
x: f64,
y: f64,
}
#[miniextendr(s7)]
impl Point {
pub fn new(x: f64, y: f64) -> Self {
Point { x, y }
}
pub fn x(&self) -> f64 {
self.x
}
pub fn y(&self) -> f64 {
self.y
}
pub fn distance(&self, other: &Point) -> f64 {
((self.x - other.x).powi(2) + (self.y - other.y).powi(2)).sqrt()
}
/// `&mut self` hands back `x`; `Invisible<()>` makes it `invisible(x)` (#1213)
#[miniextendr(s7(generic = "base::print"))]
pub fn show(&mut self) -> Invisible<()> {
println!("Point({}, {})", self.x, self.y);
Invisible(())
}
}πGenerated R Code
Point <- S7::new_class("Point",
properties = list(
.ptr = S7::class_any
),
constructor = function(x, y, .ptr = NULL) {
if (!is.null(.ptr)) {
S7::new_object(S7::S7_object(), .ptr = .ptr)
} else {
S7::new_object(S7::S7_object(),
.ptr = .Call(C_mypkg_Point__new, x, y))
}
}
)
S7::method(x, Point) <- function(x) {
.Call(C_mypkg_Point__x, x@.ptr)
}
S7::method(y, Point) <- function(x) {
.Call(C_mypkg_Point__y, x@.ptr)
}
S7::method(distance, Point) <- function(x, other) {
.Call(C_mypkg_Point__distance, x@.ptr, other@.ptr)
}
S7::method(print, Point) <- function(x, ...) {
.Call(C_mypkg_Point__show, x@.ptr)
invisible(x)
}πUsage
p1 <- Point(0, 0)
p2 <- Point(3, 4)
x(p1) # 0
distance(p1, p2) # 5
print(p1) # Point(0, 0)πFast-path dispatch shortcuts
Every non-fallback S7 instance method also gets a plain function
<ClassName>_<method>(.mx_self, ...) whose body calls the underlying Rust routine
directly, bypassing S7::S7_dispatch() (the class walk + method-table lookup).
On hot loops this is several times faster than calling the generic. Trait-impl
S7 methods (#[miniextendr(s7)] impl Trait for Type) get the same shortcuts.
The dotted receiver leaves an argument named self free; pass the object by
position. The genericβs dispatch arguments are unchanged.
# Generic (full S7 dispatch β honours subclass overrides)
distance(p1, p2)
# Shortcut (direct .Call β no dispatch)
Point_distance(p1, p2)
Footgun: the shortcut does not perform subclass dispatch β a method override defined on a child class will not be honoured. Use the generic when subclassing is possible.
Opt a method out with s7(no_shortcut) (the generic + method registration are
unaffected):
#[miniextendr(s7(no_shortcut))]
pub fn get(&self) -> i32 { ... }
Shortcut names share one R namespace with the <ClassName>_<static_method>
functions emitted for static methods. A collision inside one impl block
(typically via an r_name alias) is a compile error suggesting a rename or
s7(no_shortcut). The wrapper writer checks across separate expansions too,
including #[derive(ExternalPtr)] sidecar accessors
(<ClassName>_get_<field> / <ClassName>_set_<field>), free functions and
other class impl blocks. Rename the colliding method or field, or use
s7(no_shortcut) (see #991).
The writerβs other name checks cover every class system; see Names the wrapper writer rejects.
πWhich generic a method attaches to
The generic is the methodβs R name (r_name, or the Rust name), unless
s7(generic = "...") names another one. Three cases:
- The packageβs own generic: any unqualified name, such as
areaor a new operator%scale%. If no usable generic of that name exists when the package loads, a package-localS7::new_generic()is defined (shadowing a plain, non-generic function of the same name); otherwise the existing S7, S3, S4 or primitive generic is reused. The generic is exported and gets a standalone help page listing the classes with methods for it. - Another packageβs generic:
s7(generic = "pkg::name"), such asbase::printorgenerics::tidy. The generic is bound withS7::new_external_generic("pkg", "name", <dispatch args>)and is neither exported nor documented here. The package does not have to importpkg, or even list more thanSuggests:for it: whenpkgis not loaded, S7 attaches the method once it loads. That needsS7::methods_register()in the packageβs.onLoad(), as S7 recommends for every package (see βRegistering methods when the package loadsβ below). - A base operator (
[,[[,$, theOpsgroup,%*%): the method attaches to baseβs operator directly. Nothing is defined, exported or documented as a package generic.S7::method(`[[`, Bag) <- fis a replacement call, so R binds[[in the package namespace; the generatedbase::rm(list = "[[")on the next line removes that binding. With it, R would take[[for a generic of the packageβs own and register the packageβs S3[[methods in the namespaceβs own table, where a userβsx[[i]]never finds them.
πMultiple dispatch
s7(dispatch = "x, other") makes the generic dispatch on several arguments.
The first name is the receiver (the object the method is defined on, x by
default); the others are the methodβs leading parameters, in order:
#[miniextendr(s7)]
impl Left {
/// Pair `x` with `other`.
#[miniextendr(s7(dispatch = "x, other"))]
pub fn pair(&self, other: f64, sep: String) -> String { ... }
}pair <- S7::new_generic("pair", c("x", "other"), function(x, other, ...) S7::S7_dispatch())
S7::method(pair, list(Left, S7::class_any)) <- function(x, other, sep, ...) {
.Call(C_mypkg_Left__pair, .call = sys.call(), x@.ptr, other, sep)
}
The receiver is registered for the class, and every other dispatch argument for
S7::class_any. The methodβs own argument checks and Rust conversions are the
type check, and their errors name the parameter. No single S7 class matches
what a parameter accepts in general (a &T takes the class object, a bare
external pointer or a list with .ptr; coerce widens numeric
parameters), so a narrower class would turn accepted values into βCanβt find
methodβ errors. A more specific method registered in R,
such as S7::method(pair, list(Left, Right)), still takes precedence over the
generated one for those argument classes.
The names are checked at compile time: they must be distinct, syntactic
argument names; the receiver name cannot also be a parameter name; the others
must be the methodβs first parameters in the same order and cannot have
defaults (S7 rejects defaults on dispatch arguments). With s7(no_dots) the
generic has no ..., so S7 requires every formal of the method to be a
dispatch argument.
πOperator methods
An instance method can be called through Rβs operator syntax by giving it an operator as its generic:
#[miniextendr(s7)]
impl Bag {
/// `bag[i]`, registered on base's `[`.
#[miniextendr(s7(generic = "["))]
pub fn subset(&self, i: Vec<i32>) -> Vec<f64> { ... }
/// `bag[[i]]`
#[miniextendr(r_name = "[[")]
pub fn at(&self, i: i32) -> f64 { ... }
/// `bag %scale% k`, a package-local generic.
#[miniextendr(r_name = "%scale%")]
pub fn scale(&self, k: f64) -> Vec<f64> { ... }
/// `bag %cat% other`, also a package-local generic.
#[miniextendr(s7(generic = "%cat%"))]
pub fn concat(&self, other: Vec<f64>) -> Vec<f64> { ... }
}
The generated R backtick-quotes the operator wherever it is an R symbol
(S7::method(`[[`, Bag) <- function(x, i, ...)) and exports a new
generic such as %scale% as export("%scale%"). An operator r_name gets no
fast-path shortcut, because Bag_[[ is not a syntactic R name; the
s7(generic = ...) spelling on an ordinary Rust name keeps one (Bag_subset()
and Bag_concat() above).
Rβs Ops group (+, -, *, /, ^, %%, %/%, ==, !=, <, <=,
>=, >, &, |) and %*% are dispatched by S7 on both operands, through
its own (e1, e2, ...) and (x, y, ...) generics. A method on one of them
dispatches on that pair without an s7(dispatch = ...): the receiver becomes
e1 (x for %*%), and the method takes exactly one parameter, the right
operand, which must be named e2 (y):
#[miniextendr(s7)]
impl Money {
/// `a + b`
#[miniextendr(s7(generic = "+"))]
pub fn add(&self, e2: &Money) -> Self { ... }
/// `a * k`
#[miniextendr(r_name = "*")]
pub fn times(&self, e2: f64) -> Self { ... }
}S7::method(`+`, list(Money, S7::class_any)) <- function(e1, e2, ...) { ... }
A wrong operand name, a second parameter, a conflicting s7(dispatch = ...) or
s7(fallback) are compile errors, and so is !, which S7 does not dispatch.
Two S7 limitations apply to these methods: only Money + x is registered, so
1 + money needs a reversed method in R
(S7::method(`+`, list(S7::class_any, Money))), and unary -money fails
inside S7, which always calls the method with two operands.
πRegistering methods when the package loads
A package with S7 classes needs an .onLoad() that calls
S7::methods_register():
.onLoad <- function(libname, pkgname) {
suppressMessages(S7::methods_register())
}
S7 records a method for a generic that belongs to another package when the
package is built: a base operator (bag[i], bag[[i]], money + 1), a base
generic (format(), print()) or another packageβs generic
(s7(generic = "pkg::name")). Only S7::methods_register() registers those
methods again in a new R session. Without it they work in the session that
installed the package and fail in every later one; bag[i] then errors with
βS7 objects are not subsettableβ. Methods on the packageβs own generics are
not affected.
minirextendr::use_s7() writes this hook to R/zzz.R when the package has no
.onLoad(), and names the line to add when it has one.
minirextendr::miniextendr_doctor() warns when an S7 packageβs .onLoad() does
not call it. The suppressMessages() hides the βOverwriting methodβ messages
S7 prints under devtools::load_all(), which registers such methods twice.
πWhen to Use
- New packages without legacy constraints
- Clean, modern OOP design
- Computed and dynamic properties (see below)
- S7 ecosystem integration
πS7 Computed and Dynamic Properties
S7 supports properties that are computed from Rust methods. Use #[miniextendr(s7(getter))] for read-only computed properties and add #[miniextendr(s7(setter, prop = "name"))] for read-write dynamic properties.
πRust Code
#[derive(miniextendr_api::ExternalPtr)]
pub struct Range {
start: f64,
end: f64,
}
#[miniextendr(s7)]
impl Range {
pub fn new(start: f64, end: f64) -> Self {
Range { start, end }
}
/// Computed property (read-only): length of the range.
/// Accessed as obj@length in R.
#[miniextendr(s7(getter))]
pub fn length(&self) -> f64 {
self.end - self.start
}
/// Dynamic property getter: read the midpoint.
#[miniextendr(s7(getter, prop = "midpoint"))]
pub fn get_midpoint(&self) -> f64 {
(self.start + self.end) / 2.0
}
/// Dynamic property setter: set the midpoint.
/// Adjusts start and end to maintain length while centering on new midpoint.
#[miniextendr(s7(setter, prop = "midpoint"))]
pub fn set_midpoint(&mut self, value: f64) {
let half = (self.end - self.start) / 2.0;
self.start = value - half;
self.end = value + half;
}
/// Regular method (not a property).
pub fn start(&self) -> f64 {
self.start
}
}πGenerated R Code
Range <- S7::new_class("Range",
properties = list(
.ptr = S7::class_any,
length = S7::new_property(
getter = function(self) .Call(C_mypkg_Range__length, self@.ptr)
),
midpoint = S7::new_property(
getter = function(self) .Call(C_mypkg_Range__get_midpoint, self@.ptr),
setter = function(self, value) {
.Call(C_mypkg_Range__set_midpoint, self@.ptr, value)
self
}
)
),
constructor = function(start, end, .ptr = NULL) { ... }
)
# Regular method as S7 generic
S7::method(start, Range) <- function(x, ...) .Call(C_mypkg_Range__start, x@.ptr)πUsage
r <- Range(0, 10)
# Computed property (read-only)
r@length # 10
# Dynamic property (read-write)
r@midpoint # 5
r@midpoint <- 10 # Adjusts start/end
r@midpoint # 10
start(r) # 5 (new start after midpoint shift)
r@length # 10 (length preserved)πProperty Attributes
| Attribute | Description |
|---|---|
#[miniextendr(s7(getter))] | Read-only computed property. Property name = method name. |
#[miniextendr(s7(getter, prop = "name"))] | Getter with custom property name. |
#[miniextendr(s7(setter, prop = "name"))] | Setter for a dynamic property. Must match a getterβs prop name. |
Rules:
- A getter without a setter creates a computed (read-only) property
- A getter + setter with the same
propname creates a dynamic (read-write) property - Property methods are NOT exposed as S7 generics (accessed via
@only) - Setters must take exactly one parameter (the new value)
πFeature Comparison Matrix
πConstructor Patterns
| System | Constructor Name | Returns |
|---|---|---|
| Env | TypeName$new() | Environment with class |
| R6 | TypeName$new() | R6 object |
| S3 | new_typename() | Object with class attribute |
| S4 | TypeName() | S4 object |
| S7 | TypeName() | S7 object |
The constructor is decided for the Rust type, across all its impl blocks:
- The method marked
#[miniextendr(<system>(constructor))](r6(constructor),s7(constructor), β¦). - Otherwise a receiver-less method named
newthat returnsSelf. A vctrsnewis the constructor whatever it returns, since it returns the payload. - Otherwise the typeβs one receiver-less method returning
Self, whatever its name.Result<Self, E>andOption<Self>donβt make a method a candidate: a fallible constructor is marked with rule 1. An S7convert_frommethod isnβt a candidate either, and a vctrs class never infers its constructor.
#[miniextendr(r6)]
impl Reading {
pub fn from_value(value: f64) -> Self { Reading { value } } // the constructor: Reading$new(value), and Reading$from_value(value)
pub fn parse(text: &str) -> Result<Self, String> { /* ... */ } // a static method: Reading$parse(text)
pub fn value(&self) -> f64 { self.value }
}
A constructor whose R name isnβt new, marked (rule 1) or inferred (rule 3),
keeps that name too: it is also a static method under it, which builds the
same object (Reading$from_value() above, typename_from_value() for S3,
TypeName_from_value() for S4 and S7). A vctrs class has only its new.
Every other receiver-less method returning Self, such as a from_file
factory next to a new, is a static method. A type that has several such
methods and no declared constructor is refused, at compile time when they are
in one impl block and when the wrapper writer runs when they are in several.
The error lists them: mark one with <system>(constructor) or name it new,
and the others stay static methods.
πA class without a constructor
A type with no constructor by these rules (no marked method, no new, and no
receiver-less method returning bare Self) is defined without one. Its
objects come from its factories: static methods returning Result<Self, E> or
Option<Self>, which wrap the pointer they return in the class directly
(Class$new(.ptr = ...) for R6, Class(.ptr = ...) for S7,
methods::new("Class", ptr = ...) for S4), or functions of other classes that
return one. A type with factories returning bare Self has candidates, so it
has a constructor: the one candidate, or the one marked among several.
Nothing is generated in place of the constructor. R6βs TypeName$new() and
S7βs TypeName(), which exist anyway to wrap the factoriesβ pointers, raise
an error naming the type when called without a .ptr, rather than build an
object holding a NULL pointer. Env and S3 leave TypeName$new NULL, and
S4 and vctrs define no TypeName() or new_typename(): a package that builds
its objects in R may define its own under that name (#2069). An abstract S7
class (s7(abstract)) keeps S7βs own refusal to construct it. A vctrs class
without a constructor gets its vectors from vctrs::new_vctr()
(new_rcrd(), new_list_of()) with class = "TypeName".
When nothing creates the type, neither a constructor nor a #[miniextendr]
function or method returning it, nor code of the crate building an
ExternalPtr<TypeName> itself (e.g. a wrapperβs hand-written IntoR), the
build warns (lint MXL121, see Macro Errors). The lint sees
ExternalPtr::new of a TypeName { .. } literal, directly or through a let
in the same function, or of a local written let x: TypeName = .., and any
ExternalPtr<TypeName> written as a type; it misses a pointer built from a
value from a call whose type isnβt written.
An S7 property whose class is a class without a constructor needs a
default, since S7 builds a propertyβs default object by calling the classβs
constructor.
The class keeps the doc comments of its impl blocks in every system. A class
named as its Rust type has its class page, TypeName.Rd, titled by default.
An S3 or vctrs class the package names itself (class = "...") has no class
environment either (#1918), so its impl docs go in a roxygen block of their
own:
- Without
@rdnamein the impl docs, the block is on a page named after the class (engine.Rd, aliasedengine), titledengine S3 Class(engine vctrs S3 Class) unless the impl docs give a@title. - With
@rdname topic, the block joinstopicand adds no alias and no title: the topicβs own blocks name and title the page. A package that documents the class on a topic of its own, even one named after the class, puts@rdname <that topic>on the impl docs. Without it, the generated page and the packageβs own would both title the page. - Without impl docs there is no block.
πMethod Access
| System | Instance Method | Static Method |
|---|---|---|
| Env | obj$method() | TypeName$method() |
| R6 | obj$method() | TypeName$method() |
| S3 | method(obj) | typename_method() |
| S4 | method(obj) | TypeName_method() |
| S7 | method(obj) | TypeName_method() |
πMutable Receivers (&mut self)
All class systems support mutable receivers. The Rust method:
pub fn increment(&mut self) {
self.value += 1;
}
Modifies the underlying data in place. The R object reference remains valid.
πConsuming Receivers (self)
Methods taking self by value are supported on every class system; what the
wrapper does depends on the return type:
| Signature | Wrapper behaviour | R result |
|---|---|---|
fn step(self, ..) -> Self | moves the value out, calls, writes the result back into the same handle | the receiver object (identity preserved, like &mut Self) |
fn step(self, ..) -> Result<Self, E> / -> Option<Self> | calls on a clone (T: Clone required), overwrites the handle only on Ok / Some | the receiver object; Err raises and None gives NULL, both leaving the object untouched |
fn finish(self, ..) -> T (anything else) | moves the value out and converts T as usual; the handle is left consumed | the converted value; later use of the object errors with βwas consumedβ |
#[derive(Clone, ExternalPtr)]
pub struct Pipeline { steps: Vec<String> }
#[miniextendr(s3)]
impl Pipeline {
pub fn new() -> Self { Pipeline { steps: vec![] } }
pub fn with_step(mut self, name: String) -> Self { self.steps.push(name); self }
pub fn try_step(mut self, name: String) -> Result<Self, String> {
if name.is_empty() { return Err("empty step".into()); }
self.steps.push(name);
Ok(self)
}
pub fn run(self) -> i32 { self.steps.len() as i32 }
}p <- new_pipeline() |> with_step("load") |> try_step("fit")
try_step(p, "") # Error: empty step (p still has two steps)
run(p) # 2; p is now consumed
with_step(p, "x") # Error: this `Pipeline` object was consumed ...
self: Self is the same as self. self: Box<Self> / Rc<Self> and the
other smart-pointer receivers are rejected (the handle stores the value
itself). The former #[miniextendr(constructor)] escape hatch on a self
method is an error: a constructor has no receiver. The R6 finalizer is never
inferred from a self receiver any more; mark it with r6(finalize).
Fallible in-place steps, &mut self -> Result<&mut Self, E> and
-> Option<&mut Self>, are recognised alongside &mut self -> &mut Self:
success hands back the same handle, Err raises through the normal error
path, and None gives NULL.
An Option is an absent value, never an error, for chain steps too: None
hands NULL to the next step of obj |> step() |> next(), or into
x <- x$step(). A step whose failure must stop the chain returns
Result<Self, E> / Result<&mut Self, E>.
πConditional Methods
Methods and associated items in a #[miniextendr] impl block can carry
#[cfg(...)] / #[cfg_attr(...)]. A gated-off method has no C wrapper,
registration, R binding or docs, and constructor inference, S7 property pairing
and name checks see only the enabled methods. See
Conditional Compilation for
every place a gate can sit.
πMultiple Impl Blocks
A typeβs methods can be split over several #[miniextendr] impl blocks, in one
file or across modules, in every class system. The blocks need no names: the
wrapper writer merges them per Rust type, defines the class once and attaches
the methods of every block to it (#1776).
/// A counter.
#[miniextendr(r6)]
impl Counter {
pub fn new(start: i32) -> Self { Counter { value: start } }
pub fn value(&self) -> i32 { self.value }
}
#[miniextendr(r6)]
impl Counter {
pub fn add(&mut self, by: i32) { self.value += by }
#[miniextendr(r6(active))]
pub fn doubled(&self) -> i32 { self.value * 2 }
#[miniextendr(r6(private))]
pub fn checked(&self) -> bool { self.value >= 0 }
pub fn origin() -> i32 { 0 }
}c <- Counter$new(5L)
c$add(2L)
c$value() # 7
c$doubled # 14
Counter$origin() # 0
One block defines the class: it writes the class object (new.env(),
R6::R6Class(), methods::setClass(), S7::new_class()), the constructor,
and what the class options add (the $ / [[ field methods of
r_data_accessors, R6βs $lock()). Every block adds its methods, static
methods and as.<class>() coercions, in source order. R6 private methods,
finalize and deep_clone are $set("private", ...) calls, so any block can
have them; an R6 active bindingβs @field and a lifecycle import of any block
go to the classβs roxygen block.
Every blockβs doc comment documents the class. The classβs roxygen block has the defining blockβs impl doc comment (for S3 and vctrs, with the constructorβs), then each other blockβs, in source order:
- Prose and the tags roxygen2 collects (
@description,@details,@section,@seealso,@family,@importFrom, β¦) add up; each blockβs prose becomes another@descriptionparagraph. A tag repeated exactly, such as one@importFromor@familyon two blocks, is written once. (@return,@examplesand@exportbelong on the methods, as for one block, and so does@paramoutside R6.) - A generated line gives way to the same tag on another block: the
@title <Class> <system> Classdefault,@name,@rdname,@export, a constructor@paramfiller and an R6 active bindingβs generated@field. So when the defining block has no doc comment, another block can title the class. - A class has one title: two blocks with different
@titles stop the install. - The defining block decides the classβs page. A
@nameor@rdnameon another block must repeat the defining blockβs (its own, or the default<Class>); a different one stops the install, because the defining blockβs generated documentation was written for its page. Put page tags on the defining block.
Each methodβs doc comment documents that method, whichever block has it. An R6
impl-level @param, which roxygen2 hands down to the classβs methods, spares
the methods of its own block their (no documentation available) placeholder;
a method in another block documents the argument itself (#2066).
An @rdname topic on the defining block moves the whole class to topic, not
only its roxygen block: the constructor, the methods and static methods of
every block, and the helpers the class generates for them (S3 generics, S4
setMethod()s, S7 shortcuts and convert() methods, vctrs protocol methods,
as.<class>() coercions, the field methods) go there too, and no
<Class>.Rd is written (#2068). A methodβs own @rdname or @describeIn
still sends that method elsewhere. Trait impl methods (impl Trait for Type)
do not follow yet and stay on <Type>.Rd; give each one @rdname topic until
#2144 is fixed.
On topic the members sort after the topicβs own block (@order NaN), so
that block names and titles the page, and they add no generated @param
lines: a later @param would replace the topicβs text for the same argument.
The topicβs block therefore documents every argument of the classβs members,
the generated ones included (x, ..., an S7 shortcutβs .mx_self, an S7
constructorβs .ptr, envβs self, name and exact, vctrsβ y and to).
The blocks of a type follow four rules, checked when the wrapper writer
assembles R/<pkg>-wrappers.R during R CMD INSTALL; a violation stops the
install with an error naming the blocks:
- One block defines the class. Only one block may have the constructor,
S7 properties (
s7(getter),s7(setter),s7(validate)) and a field fallback (s3(r_data_accessors, fallback = "..."), whose field methods that block writes). That block defines the class; without one, the first block in source order does. The constructor is decided for the type (Constructor Patterns): a blockβs one method returningSelfis the constructor when no block declares one, and a static method when another block has anewor a marked constructor. Methods returningSelfin two blocks, with none declared, are refused. - The doc comments agree on the title and page: one
@titlevalue, and a@name/@rdnameonly as the defining block has it (see above). - The blocks agree on the class settings: the class system with its options
(
r6(lock_class = TRUE),s3(r_data_accessors),vctrs(kind = ...), β¦),class = "...",internal/noexport, and the@noRd/@keywords internaltags. These decide how every blockβs methods are generated, documented and exported, so each block spells them; the class docs have them once.strictandpreconditionsare per-block method defaults and may differ. - No two blocks bind one member: an env or R6 member, an R6 static method,
an S4 or S7 method of the class. Rust already rejects two methods of one name
(E0592); this catches two R names made equal with
r_name = "...".
πNames the wrapper writer rejects
Each class, generic and function the macros generate is bound by name in the
package namespace, and a later binding of a name replaces an earlier one when
the package loads. A collision between two macro expansions is only visible
when the wrapper writer assembles R/<pkg>-wrappers.R during R CMD INSTALL,
so the install stops there, with an error naming both producers (the Rust impl,
function or sidecar field, with its source position):
- Two Rust types with one R class name, through
class = "..."or two types of one name in different modules. Their class objects and methods would overwrite each other. - A class object and a function of the same name: the Env or S3 class
environment (
Type <- new.env(...)),R6::R6Class(...)orS7::new_class(...), against a free#[miniextendr]function, an S7 shortcut or a sidecar accessor. S4βsmethods::setClass()binds no name, so the S4 constructor function shares the class name as intended. - Impl blocks of one type that break the rules of Multiple Impl Blocks: two blocks that each define the class, blocks with different class settings, or a member bound by two blocks.
- A generic and a plain binding of the same name. The S3, S4 and S7
generics that methods attach to are created inside
if (...)guards. Several classes may declare the same generic so their methods share dispatch, but a function or class object with the genericβs name would replace it or be replaced by it. - Two functions of the same name, such as an S7 shortcut and a sidecar accessor or a free function (#991), including two blocks of one type whose S3 methods or static methods get one name.
Rename the function or method with r_name = "...", or the class with
class = "...".
πCross-Type Class Name References
Several attributes let one #[miniextendr] type reference another by name -
s7(parent = ...), r6(inherit = ...), s7(convert_from = ...), and
s7(convert_to = ...). miniextendr resolves these at wrapper-gen time using
the Rust type name, not the R class name, so you can stay consistent with your
Rust source and still get the correct R-visible name in generated wrappers.
#[miniextendr(s7, class = "Shape")]
impl S7Shape {
pub fn new() -> Self { S7Shape }
}
#[miniextendr(s7(parent = "S7Shape"))] // Rust name on the left
impl S7Circle {
pub fn new() -> Self { S7Circle }
}
Generated R uses the registered class name:
S7Circle <- S7::new_class("S7Circle", parent = Shape, ...)
The same placeholder (.__MX_CLASS_REF_<Type>__) powers R6 inherit = and S7
convert_from / convert_to. Unregistered names produce a compile-time warning
and fall through to the bare identifier - which will fail at R load.
πExplicit Return Class Systems
A return can choose its class system directly with WrapAsR6<Board> or
#[miniextendr(wrap = "r6")] on a function or method returning Board.
The corresponding markers for S7, S4, S3, Env, and Vctrs use the same shared
wrapping decision. For example, an Env factory returning an R6 board emits
Board$new(.ptr = .val), and a trait factory can use WrapAsR6<Self>.
Explicit wrapping uses the final Rust type-name segment as the R class name
(Self uses the implementing class name), without a registry lookup. The
selected system and name must match the targetβs R definition. See
explicit cross-class return wrapping
for both spellings, container/error behavior, visibility composition, and
vctrs class preservation.
πS7 Property Class Constraints
When an S7 getter method returns another #[miniextendr] type, the generated
property declares that class as its constraint:
#[miniextendr(s7)]
impl S7PropOuter {
pub fn new(inner: S7PropInner) -> (Self, /* sidecar */) { ... }
#[miniextendr(s7(getter))]
pub fn inner(&self) -> S7PropInner { ... }
}S7PropOuter <- S7::new_class("S7PropOuter",
properties = list(
inner = S7::new_property(
class = S7PropInner, # resolved class name
getter = function(self) .Call(..., self@.ptr)
)
)
)
Property-class resolution uses a sibling placeholder
(.__MX_CLASS_REF_OR_ANY_<Type>__) with a silent fallback to
S7::class_any in two cases, so property getters returning βforeignβ types
donβt break package load:
| Getter return type | Emitted class |
|---|---|
| Registered S7 class | <R class name> |
| Registered non-S7 class (R6 / S3 / S4 / Env / vctrs) | S7::class_any |
Unregistered / primitive / SEXP / PathBuf | S7::class_any |
The other four reference sites (parent, inherit, convert_from,
convert_to) keep the loud fallback - an unresolved name there almost always
means a typo worth surfacing.
πHelp Pages and Doc Tags
πOne page per class, @rdname to split
Every generated wrapper for a class (constructor, instance method, static
method, as.* coercion, S7 convert, trait-impl method) is documented with
@rdname <Class>, so a class renders as a single help page listing all of its
methods. That is the right default for a handful of accessors; for a
15-verb builder it means one long page.
A method-level @rdname splits a method onto its own page. The generator
honours it wherever it would otherwise inject the class default, and never
emits both:
#[miniextendr(s3)]
impl Pipeline {
/// Run the pipeline.
///
/// Long-form documentation that deserves its own page.
/// @rdname run_pipeline
/// @param inputs Numeric inputs.
pub fn run(&self, inputs: Vec<f64>) -> Result<Output, String> { /* ... */ }
}
renders man/run_pipeline.Rd for run.Pipeline while the rest of the class
stays on man/Pipeline.Rd. Applies to all class systems and to
#[miniextendr] impl Trait for Type blocks. The S4/S7 generics the
generator emits alongside a method stay on the class page, as do trait
consts. The S3 generic guard is documented under the methodβs own
generic.Class name (so classes sharing a generic donβt collide on
\alias{generic}), which is the same alias the method produces; it therefore
moves to the split page with the method, otherwise R CMD check reports the
alias as duplicated across the two pages. A method-level @name is honoured
the same way.
Method prose is emitted as @description (the class page supplies the
@title), so a split page would have no title and roxygen2 would skip it.
The generator therefore adds @title <R name> (run.Pipeline above) to a
split method unless the doc comment carries its own @title.
Exception: R6 instance methods. They are emitted as Class$set("public", ...) blocks, which roxygen2 folds into the class block (its R6 support
introspects the generator and renders every method in the class pageβs
Methods section). A method-level @rdname there would fail with βBlock must
contain only one @rdnameβ, so the generator drops it; R6 static methods
(Class$fn) split like any other.
πImpl-block doc tags
Roxygen tags on the impl blockβs own /// comment describe the class, so
the following are stripped from that comment (with a build-time deprecation
warning) because they only make sense on a method:
@param: a class has no arguments; document constructor arguments on the constructor (new) method, or at the class level for R6 where roxygen2 8.0.0 inherits class-level@paraminto every method (so it is kept for R6 impl blocks).@return/@returns: an impl block returns nothing.@examples: examples belong on the method they demonstrate.@export: export visibility is controlled by#[miniextendr(internal)]/#[miniextendr(noexport)], not by roxygen.
Tags on method doc comments are not stripped: @param, @return,
@examples, @rdname, @name, @seealso, and so on flow through to the
generated wrapper verbatim (undocumented parameters get an auto-generated
@param name (undocumented) line, except when the methodβs own @rdname,
@describeIn or @inheritParams sends it to a page that documents them; see
Parameters on shared pages; env
methods, which have no usage section, get no filler, and their choice
parameters get a \describe item instead). A
method that joins another topic also drops the generatorβs fixed x / ...
lines and sorts after that topicβs own block. @describeIn works on the
methods whose wrapper is a plain R function (S3 instance methods, static
methods, S4 constructors) and is a compile error elsewhere; see the same
section. Trait-impl methods (impl Trait for Type) forward only their
@param lines and these page tags: @describeIn, @rdname, @name,
@order, @inheritParams, @inherit and @inheritDotParams. Env trait
methods, the Type$Trait$method statics of S3 and vctrs classes and the
attr(Type, "Trait")$method statics of S7 classes are documented like env
methods: every tag but @title and a bare @export (which would export the
member itself) flows through, and their @param lines and choice text go into
a \describe list. S4 statics
(Type_Trait_method()) get @param lines, generated ones included. R6
constructor and instance methods are documented on the class page whatever
their tags, so they keep the generated line. The one exception is class
systems whose
methods are registered by assignment (S4 setMethod(), S7 S7::method()),
where per-method @param would document arguments roxygen2 cannot see in
\usage; those generators drop method @param tags to keep R CMD check
quiet.
A tag that flows through keeps its blank lines and indentation, as on a function; see Doc comments to roxygen.
S4βs structural tags (@exportClass, @exportMethod, @exportPattern) pass
through unchanged. Tag-name matching is exact on the first
whitespace-delimited token.
πGenerated Wrapper Annotations
Each generated R wrapper (constructor, instance method, or static method)
carries a # Type::method (line:col) source-location comment just above
the .Call() line, pointing back at the Rust declaration. This is emitted
for all six class systems (env, R6, S3, S4, S7, vctrs) and makes
rpkg/R/*-wrappers.R diffs easy to trace when macro changes shift
generated code around.
πTrait Implementations
For cross-package interoperability:
#[miniextendr]
pub trait Counter {
fn value(&self) -> i32;
fn increment(&mut self);
}
#[miniextendr]
impl Counter for MyCounter {
fn value(&self) -> i32 { self.count }
fn increment(&mut self) { self.count += 1; }
}
This enables type-erased dispatch across package boundaries.
πDirect Field Access via Sidecar
Sidecar fields (#[r_data] + RSidecar) are the way to expose struct fields
to R. Every public field gets generated accessors, in a form that depends on
the class system.
πHow It Works
- On the
#[derive(ExternalPtr)]struct, add an#[r_data] _r: RSidecarselector and mark each exposed field#[r_data]. Fields without it stay Rust-only. - Each
pub#[r_data]field getsType_get_<field>(x)/Type_set_<field>(x, value). They are exported, documented on a βType Sidecar Accessorsβ page, unless the class uses the S3, S4 or env field syntax below: then$/[[are the classβs R API and the standalone accessors are internal, for the packageβs own R code. r_data_accessorson the impl also wires the fields into the class:#[miniextendr(r6(r_data_accessors))]makes them active bindings,#[miniextendr(s7(r_data_accessors))]S7 properties, ands3(r_data_accessors),s4(r_data_accessors)orenv(r_data_accessors)$/[[methods that read them,$<-/[[<-methods that write them, and.DollarNames()for completion; an S3 class also getsnames()andas.list(). On S3, S4 and env,r_data_accessors = "get"generates the readers only.
πRust Code
use miniextendr_api::externalptr::{Computed, RSidecar, Sidecar};
#[derive(ExternalPtr)]
#[externalptr(s3)]
pub struct MyConfig {
// Rust-only internal state
cache: Vec<u8>,
#[r_data]
_r: RSidecar,
// Fields exposed to R
#[r_data]
pub name: String,
#[r_data]
pub score: f64,
#[r_data(name = "table")]
pub r_table: Sidecar<SEXP>, // any R value, e.g. a data frame; `table` in R
#[r_data(get = "Self::cache_size")]
pub cache_size: Computed, // read-only, computed from the Rust value
}
impl MyConfig {
fn cache_size(&self) -> i32 {
i32::try_from(self.cache.len()).unwrap_or(i32::MAX)
}
}
#[miniextendr(s3(r_data_accessors))]
impl MyConfig {
pub fn new(name: String, score: f64) -> Self {
MyConfig {
cache: vec![],
_r: RSidecar,
name,
score,
r_table: Sidecar::new(SEXP::nil()),
cache_size: Computed,
}
}
pub fn has_table(self: ExternalPtrRef<'_, Self>) -> bool {
!Self::r_table(&self).is_nil()
}
}
name and score live in the Rust struct: each read converts the Rust value
to R, and each write converts into Rust or raises an error. r_table is a
Sidecar<T>: its value lives in the external pointerβs protection list, which
roots it, and Rust reads and writes it through the MyConfig::r_table(&ptr) /
MyConfig::set_r_table(&mut ptr, v) accessors the derive generates, so a
method that needs it takes a view as its receiver (self: ExternalPtrRef<'_, Self>, as has_table does). The field types, the Sidecar<T>
lifecycle and what survives saveRDS() are in EXTERNALPTR.md, βRSidecar (R
Data Fields)β.
πR names
#[r_data(name = "table")] gives a field its R name. Everything R sees uses
it: the field list and the $ / [[ labels, the R6 active binding or S7
property, the standalone MyConfig_get_table() / MyConfig_set_table() and
the setterβs 'table' must be ... message. The Rust side keeps the Rust
identifier: MyConfig::r_table(&ptr), and the C symbols. The name must be a
syntactic R name (n.rows is fine, n rows and for are not), unique among
the typeβs R names, and not .ptr, which is the element that carries the
pointer in a list or environment receiver. A Sidecar fieldβs R name also
names its slot in a save: renaming the Rust field keeps earlier saves
readable, renaming the R name does not (EXTERNALPTR.md, βA save names
Sidecar values by their R namesβ, #2215).
πComputed fields
A Computed field is a read-only R field computed from the Rust value by the
function get names, a plain fn(&Self) -> impl IntoR (Self is the
struct; a free function of the module works too). It needs no
#[miniextendr], and the field holds nothing: it is a zero-sized marker that
gives the computed field its place in the field order, so every struct
literal writes cache_size: Computed once, as it writes _r: RSidecar.
Computed fields take part in everything the other fields do, at their
declared place: the standalone MyConfig_get_cache_size(x), and x$cache_size
/ x[["cache_size"]] on S3, S4 and env classes with r_data_accessors. They
have no setter: no MyConfig_set_cache_size(), and under the get/set form
x$cache_size <- v raises miniextendr_read_only_field (`cache_size` of `MyConfig` is computed from the Rust value and can't be assigned, naming
the objectβs R class). A
computed field exists only for R, so it must be pub; and it canβt be an R6
active binding or an S7 property, which need a setter (#1888), so it is
refused under #[externalptr(r6)] / #[externalptr(s7)]; the example above
is an S3 class for that reason. The getterβs errors are its own: a
rust_error!(class = "pkg_error", "...") in it reaches R with that class and
the call as written (x$cache_size). It takes &Self, so it canβt read
Sidecar values, which are R fields already; it returns a value, not a
Result.
πR Behavior by Class System
| System | Get | Set | Computed fields | A restored pointer (readRDS()) |
|---|---|---|---|---|
R6 with r6(r_data_accessors) | obj$name (active binding) | obj$name <- "new" | refused at compile time (#1888) | the bindings read and write the saveβs Sidecar values |
S7 with s7(r_data_accessors) | obj@name (S7 property) | obj@name <- "new" | refused at compile time (#1888) | the properties read and write the saveβs Sidecar values |
S3 with s3(r_data_accessors) | obj$name, obj[["name"]] | obj$name <- "new", obj[["name"]] <- "new" | obj$cache_size; obj$cache_size <- v raises miniextendr_read_only_field | the field methods refuse it |
S4 with s4(r_data_accessors) | obj$name | obj$name <- "new" | obj$cache_size; the assignment raises miniextendr_read_only_field | the field methods refuse it |
Env with env(r_data_accessors) | obj$name, obj[["name"]] | obj$name <- "new", obj[["name"]] <- "new" | obj$cache_size; the assignment raises miniextendr_read_only_field | the field methods refuse it |
S3, S4, env with r_data_accessors = "get" | as above | the classβs own $<-, which can call the internal MyConfig_set_name(x, "new") | obj$cache_size; no generated assignment | the field methods refuse it |
| vctrs, and any class without the option | MyConfig_get_name(x) | MyConfig_set_name(x, "new") | MyConfig_get_cache_size(x) | the standalone accessors read and write the saveβs Sidecar values |
The standalone functions take the external pointer, or any object that
carries it: an environment or list with a .ptr element, an R6 object, an
S4 object with a ptr slot, or an object with a .ptr attribute (an S7
objectβs .ptr property). They exist for every class system. On an S3, S4
or env class with the field syntax they are internal: no export() and no
help page, since the generated $<- of an immutable class would otherwise
sit next to an exported setter that writes through every copy, and a getter
that reads the saved object the class refuses. The packageβs own R code
still calls them, e.g. to read a saved objectβs Sidecar values (below).
The field methods are documented on the classβs page, or on the topic an
@rdname in the impl blockβs doc comment names. On the authorβs topic they
add no @param lines (the topicβs own block documents x, name, i,
..., exact, value and pattern) and sort after its block (@order NaN). The
classβs other members follow the same topic
(Multiple Impl Blocks).
πField syntax on S3, S4 and env classes
The generated methods call the same accessors as the standalone functions, so
a value is converted, checked and stored the same way, and a failed
conversion raises the setterβs argument error with the call as written
(x$score <- value). They differ from list fields in two ways:
- A write changes the object in place. The field lives in the Rust value
or the pointerβs protection list, and every copy of the R object shares the
pointer, so
y <- x; y$score <- 2also changesx$score. That is the semantics of an R6 or env object, not of a list. A class that needs a copy-on-modify$<-takesr_data_accessors = "get"and writes its own$<-method that returns a new object. - No read matches a prefix of a name.
x$tanever readsx$tags, on any class system and on any receiver: a pointer is not a list, and a prefix match hides a typo (#1922). - An S3 bare pointer refuses a name that is not a field.
x$tagsandx[["tags"]]take an exact field name;x$nope,x$taandx[["nope"]]raiseminiextendr_no_field, naming the R class and its fields, as a write does.x[[2]]takes a whole-number position in the field order;x[[9]]past the last field raisessubscriptOutOfBoundsErrorwith the callx[[9]], and so do0and a negative position, which on a list would raise Rβs ownget1indexerror or drop an element.x[[TRUE]],x[[NA]],x[[1.5]]and any other index (a vector of another length, a list) raisenotSubsettableErrorfrom the classβs own[[, on the call as written (x[[1:2]]). All of these carry the typeβsfield_error(class = ...)in front. The writes take an exact name only, and a position goes to Rβs own[[<-. lookup = "list"keeps a listβs other lenience. Withs3(r_data_accessors, lookup = "list")(or= "get") the bare pointer readsNULLfor an unknown name,""andNA, and[[takes a position as a listβs does (2.9is the second field, a logical is a position,NAisNULL; out of range is stillsubscriptOutOfBoundsError). Names are still matched exactly.lookup = "strict"is the default spelled out.- A name that is not a field goes to the objectβs own elements when it
is a list or an environment carrying the handle in
.ptr, by the exact name:$reads a listβs element with.subset2()(Rβs own$on a list would match a prefix) and an environmentβs binding with Rβs$, and[[goes to Rβs own[[withexact = TRUEwhatever the call passed. On an S4 object it raisesminiextendr_no_field. On an env class,$looks the name up among the fields first, then among the methods, and givesNULLfor neither.
The rule for each receiver:
| Receiver | $ | [[ by name | [[ by position | $<- / [[<-, unknown name |
|---|---|---|---|---|
| S3 bare pointer | exact, else the fallbackβs value, else miniextendr_no_field | exact, else the fallbackβs value, else miniextendr_no_field | a whole number in range; out of range, subscriptOutOfBoundsError; a logical, NA or fraction, notSubsettableError | miniextendr_no_field |
S3 bare pointer, lookup = "list" | exact, else the fallbackβs value, else NULL | exact, else the fallbackβs value, else NULL | field order, as a listβs (truncated, a logical too, NA is NULL); a position that picks no field, subscriptOutOfBoundsError | miniextendr_no_field |
S3 list or environment carrying .ptr | an exact field, else its own element by the exact name | an exact field, else its own [[ with exact = TRUE | its own [[ | its own |
| env class | a field, then a method, then the fallbackβs value, else NULL | the same | none | miniextendr_no_field |
| S4 | an exact field, else the fallbackβs value, else miniextendr_no_field | none | none | miniextendr_no_field |
βThe fallbackβs valueβ applies to a class with fallback = "..." (below).
An unused argument is refused on an S3 bare pointer. [[,
as.list() and [[<- take ... because their generics do, and on the
bare pointer they read none of it. So there they refuse every argument in
it, as a method with #[miniextendr(dots = empty)] does
(DOTS_TYPED_LIST.md):
one miniextendr_error_unused_argument on the call as written, behind the
typeβs field_error(class = ...) classes, naming each argument (..<i> for
an unnamed one) and the formal it most likely meant. The check reads only
the names, so nothing in ... is evaluated, and a refused [[<- writes
nothing.
x[["data", exatc = TRUE]]
#> Error in x[["data", exatc = TRUE]] :
#> unused argument 'exatc' (did you mean 'exact'?)
x[[1, 2]]
#> Error in x[[1, 2]] : unused argument '..1' (unnamed)
as.list(x, all.names = TRUE)
#> Error in as.list(x, all.names = TRUE) : unused argument 'all.names'
x[["data", 2]] <- 3
#> Error in x[["data", 2]] <- value : unused argument '..1' (unnamed)
[[ takes exact as a formal after ..., as [[.data.frame does
(function(x, i, ..., exact = TRUE)), so x[["data", exact = FALSE]] is
accepted. On the bare pointer it changes nothing, since field names always
match exactly. A list or environment receiver hands its arguments on as
before: [[ to its own [[, with exact = TRUE whatever the call passed,
as.list() to the listβs own or to as.list.environment(), which takes
all.names and sorted, and [[<- to its own [[<-. An env classβs [[
(function(self, name, ..., exact = TRUE)) takes exact too, ignored, and
refuses every other argument on any receiver, since it hands none on; its
$<- / [[<- (function(x, name, value)) have no ..., so R refuses an
extra argument there itself. S4 field syntax has no [[.
names(), as.list() and .DollarNames(). On an S3 bare pointer,
names(x) is the field names in declared order (computed fields included)
and as.list(x) a named list of every field read through the getters, so
lapply(x, f) and vapply(x, f, ...) iterate the fields; a NULL field is
kept (length(x) stays a pointerβs 1). On a list or environment receiver
both are Rβs own, so names(x) agrees with length(x) and x[[i]] there
(.DollarNames() still lists both); an environment gets
as.list.environment() by name, since its class attribute would otherwise
send as.list() to the default method, which canβt coerce an environment.
.DollarNames(x, pattern), which Rβs
completion engine calls for x$<Tab>, completes the fields on every system,
plus a listβs or environmentβs own names on an S3 receiver and the methods
on an env object. It reads only names, so it never raises. It is registered
as S3method(utils::.DollarNames, Class) (roxygen2βs @exportS3Method utils::.DollarNames): the unqualified S3method(.DollarNames, Class) fails
to load with only base attached, which is how R CMD check loads a
namespace. utils dispatches it as an S3 generic, so an S4 class takes the
same S3 method.
$ | [[ | names() | as.list() | .DollarNames() | $<- | [[<- | |
|---|---|---|---|---|---|---|---|
| S3 | $.Class | [[.Class | names.Class | as.list.Class | .DollarNames.Class | $<-.Class | [[<-.Class |
| S4 | setMethod("$") | (none) | (none) | (none) | .DollarNames.Class | setMethod("$<-") | (none) |
| env | $.Class (fields, then methods) | [[.Class (same) | (none) | (none) | .DollarNames.Class (fields, then methods) | $<-.Class | [[<-.Class |
The class canβt define these methods itself next to the option: the impl
block is refused at compile time when one of its methods is a method the
option generates ($, [[, names, as.list, .DollarNames, $<- and
[[<- on S3; $, .DollarNames and $<- on S4). A class that writes its
own $<- takes r_data_accessors = "get", which generates the readers
only. The impl block needs no constructor: a package that builds its
objects itself (structure(make_engine(), class = "engine")) writes
#[miniextendr(s3(r_data_accessors = "get"), class = "engine")] impl Engine {} and gets the readers for the pointer it classes by hand. Such an impl
emits no class environment (engine <- new.env(...)), so the packageβs own
constructor can be named engine() without a second binding. The environment
exists only when it can hold something: Class$new() for an impl with a
constructor, or a traitβs Type$Trait namespace for a class named as its
Rust type. The readers are documented on the classβs topic (or the implβs
@rdname) under #[miniextendr(internal)] too, which only drops the exports
(#1918).
πA fallback for names no field has
A class whose fields are few but whose value answers many names, such as a results object whose users want its tableβs columns, names a Rust function the readers call for a name no field has (#2150):
#[derive(ExternalPtr)]
#[externalptr(s3)]
pub struct Results {
#[r_data]
_r: RSidecar,
#[r_data]
pub id: i32,
table: Vec<(String, Vec<f64>)>,
}
impl Results {
fn column(&self, name: &str) -> Option<Vec<f64>> {
self.table.iter().find(|(c, _)| c == name).map(|(_, v)| v.clone())
}
fn column_names(&self) -> Vec<String> {
self.table.iter().map(|(c, _)| c.clone()).collect()
}
}
#[miniextendr(s3(
r_data_accessors = "get",
fallback = "Self::column",
fallback_names = "Self::column_names",
))]
impl Results { /* ... */ }x$id # the field
x$alpha # column(x, "alpha"), when no field is named `alpha`
x[["alpha"]] # the same
x$nope # column() is None: miniextendr_no_field
fallback names a fn(&Self, &str) -> Option<T> with T: IntoR, and the
optional fallback_names a fn(&Self) -> Vec<String>; both are paths as
in #[r_data(get = "Self::f")] (a leading Self is the implβs type, a free
function of the module works too), and neither needs #[miniextendr]. The
same keys go under s4(...) and env(...). The readers handle the
Option themselves: Some(v) reads v converted with IntoR, so
Some(None) of an Option<Option<i32>> reads NA, and None reads what a
name no field has reads without a fallback:
| Class | None reads |
|---|---|
| S3, the default lookup | miniextendr_no_field, with the typeβs field_error(class = ...) and no_field_hint |
S3, lookup = "list" | NULL (and x[[NA_character_]] is NULL without asking the fallback) |
| S4 | miniextendr_no_field |
| env | NULL; $ tries the fields, then the methods, then the fallback, so a value of the fallback never hides a method |
The fallback answers only what no field does:
- Fields win. A field hides a fallback name of the same name, so adding a field never changes what an existing name reads.
- No prefix matches, of a field or of a fallback name.
[[with a position counts the fields only;names()andas.list()stay the fields, soas.list(x)does not grow with the table.- The writes are the fieldsβ only.
x$alpha <- vandx[["alpha"]] <- vraiseminiextendr_no_field, as for any name that is not a field. - The bare pointer only. An S3 list or environment carrying the pointer in
.ptrkeeps its own elements for the names no field has, as without a fallback, and its.DollarNames()its own names. .DollarNames(x, pattern)offers the fields, then (on an env class) the methods, thenfallback_names(), each name once, all filtered bypattern. Withoutfallback_namesit offers the fields only.
The fallback runs on the live Rust value, borrowed as a fieldβs getter
borrows it: a restored pointer (readRDS()) raises the typeβs restored
error before the function runs, even without refuse = "every_read", and a
running method holding the value mutably raises
miniextendr_borrow_conflict. The functionβs own errors
(rust_error!(class = ...)) reach R with their classes and the call as
written (x$alpha). .DollarNames() never raises on a restored pointer: it
returns character(0) under refuse = "every_read", else the fields
without the fallbackβs names.
fallback needs r_data_accessors, fallback_names needs fallback, and
both are compile errors under r6(...), s7(...) and vctrs(...), whose
fields are a fixed set of active bindings or properties. The fallback is part
of the class one impl block defines, as the constructor is (see
Multiple Impl Blocks): give it on the block with the
constructor, and spell the same r_data_accessors options without it on the
others. A fallback on another block stops the install with the βtwo impl
blocks each define the classβ error; two blocks that both give one donβt
compile, since each emits the fallbackβs .Call entry
(C_<crate>__mx_rdata_fallback_<Type>, βdefined multiple timesβ or βsymbol
β¦ is already definedβ).
πA restored pointer
saveRDS() keeps a pointerβs Sidecar values but not its Rust value
(EXTERNALPTR.md, βSerializationβ). The generated field methods refuse a
pointer readRDS() brought back, whatever the field: x$keys, x[["keys"]],
x$keys <- v and x[["keys"]] <- v on an S3, S4 or env object raise
miniextendr_restored_no_value (a save by this version of the package) or
miniextendr_restored_other_version (a save by another), both with the class
miniextendr_restored. A reloaded object is refused, not recovered; the
class re-creates it. The standalone Type_get_f() / Type_set_f(), the R6
active bindings and the S7 properties keep reading and writing a same-version
saveβs Sidecar values, as the way to reach a saved objectβs data. The
methods pass a require_live flag to the same per-field accessor, so a read
costs no extra .Call(), and as.list() refuses because its first read
does. Only a read of a field reaches Rust: on a restored bare pointer
names(), .DollarNames() and an unknown name (NULL) still answer,
unless the type asks for refuse = "every_read" (below), or, for the
unknown name, has a field fallback, which needs the Rust value.
A type names its own condition classes and message for every one of its restored refusals, the field methods and the methods that take the handle alike:
#[derive(ExternalPtr)]
#[externalptr(s3, restored(
class = ["pkg_error_saved_object", "pkg_error"],
message = "this engine was saved; build a new one with engine()",
))]
pub struct Engine { /* ... */ }
The classes go in front of the miniextendr_restored* classes, which stay,
in the given order (one string, or a list); the message replaces
miniextendrβs in both the no-value and the other-version case, and the
conditionβs saved_version / current_version fields stay. The message is
a static string per type: one type maps to one R class, so the package can
name it literally.
A class whose contract is that a reloaded object is refused on every use adds
refuse = "every_read" to restored(...). Then every S3 reader ($, [[,
names(), as.list()) checks a bare pointer, and a list or environment
receiver, before it looks at the name or index, so names(x), an unknown
name, a position past the last field and an index that is neither a name
nor a position all raise the typeβs restored condition. A list or
environment of the class that carries no pointer (the shape an earlier
release of the package may have saved) is refused on every read too, with
miniextendr_no_handle behind the restored(class = ...) and
field_error(class = ...) classes. A list carrying a live .ptr answers
as before. .DollarNames(x) returns character(0) for a restored pointer
(it serves completion, so it never raises). The check is one more .Call()
per read, paid only by the types that opt in.
πThe packageβs classes on the readersβ own errors
A package that raises every error under its own classes names them for the field readersβ own errors too:
#[derive(ExternalPtr)]
#[externalptr(s3, field_error(class = ["pkg_error_field", "pkg_error"]))]
pub struct Engine { /* ... */ }
The classes go in front of each errorβs own class, which stays: a position
that picks no field (subscriptOutOfBoundsError), an index that is neither
a name nor a position (notSubsettableError), a name that is not a field
(miniextendr_no_field), a pointer of another type under the class, and a
receiver of the class that carries no pointer (miniextendr_no_handle). A
list or environment without a pointer, e.g. an older format, also gets the
restored(class = ...) classes first, then these, each class once at its
last place, so a parent class both lists end in follows both specific
classes, and the restored(message = ...) text when the type sets one; an
atomic vector given the class was never saved, so it gets these alone.
The receiver refusals name the class the user sees: this "engine" object holds a pointer of another type, ... holds no pointer, ... holds a null pointer (the receiverβs first class; a receiver without one is named by
the Rust type). field_error(message = "...") replaces that text with the
packageβs own, for every one of them except a list or environment without
a pointer under a restored(message = "..."), which reads as a save:
#[derive(ExternalPtr)]
#[externalptr(s3, field_error(
class = ["pkg_error_field", "pkg_error"],
message = "not an engine; build one with engine()",
))]
pub struct Engine { /* ... */ }
A name that is no field is refused with miniextendr_no_field, whose
message lists the fields: `value` is not a field of `engine`; its fields are `input`, `table`. field_error(no_field_hint = "...") adds
the packageβs own sentence after that list, each %s in it replaced by the
name asked for. Any other % in it is written as given. It applies to every
name the field methods refuse: $ and [[, and $<- / [[<- where they
are generated. Without it the message is unchanged.
#[derive(ExternalPtr)]
#[externalptr(s3, field_error(
class = ["pkg_error_field", "pkg_error"],
no_field_hint = "A column of the table is `as.data.frame(x)$%s` or `x$table$%s`.",
))]
pub struct Engine { /* ... */ }conditionMessage(tryCatch(x$value, error = identity))
#> [1] "`value` is not a field of `engine`; its fields are `input`, `table`. A column of the table is `as.data.frame(x)$value` or `x$table$value`."πConditions the generated accessors raise
| Error | Class | Raised by |
|---|---|---|
| a restored pointer | miniextendr_restored_no_value or miniextendr_restored_other_version, both with miniextendr_restored, behind the typeβs restored(class = ...) | the field methods, as.list() included; a struct or computed fieldβs standalone getter; a Sidecar fieldβs standalone accessors for another versionβs save |
[[ with a position that picks no field (past the last, 0, negative) | subscriptOutOfBoundsError (Rβs own class), behind the typeβs field_error(class = ...) | an S3 bare pointer |
| a pointer of another type under the class | rust_error, behind the typeβs field_error(class = ...) | every accessor |
| a receiver of the class with no pointer | miniextendr_no_handle, behind the typeβs field_error(class = ...), and restored(class = ...) for a list or environment | every accessor; every S3 reader under refuse = "every_read" |
[[ with an index that is neither a name nor a whole-number position (under lookup = "list", a logical or a fraction is a position) | notSubsettableError (Rβs own class), behind the typeβs field_error(class = ...) | an S3 bare pointer |
no such field (the message lists the fields, then the typeβs field_error(no_field_hint = ...)) | miniextendr_no_field, behind the typeβs field_error(class = ...) | S4 $; $, [[, $<- and [[<- on an S3 bare pointer (lookup = "list" reads NULL instead); with a fallback, the reads when it has no value for the name |
| the field fallbackβs own error | whatever the function raises (rust_error!(class = ...)) | the reads of a name no field has |
an argument in ... (the message names each one, and the formal it most likely meant) | miniextendr_error_unused_argument, behind the typeβs field_error(class = ...), then the crateβs conversion_error_class and rust_error | [[, as.list() and [[<- on an S3 bare pointer; an env classβs [[ |
| an assignment to a computed field | miniextendr_read_only_field | $<- / [[<- in the get/set form |
| a failed conversion | rust_error plus the crateβs conversion_error_class | the setters, with 'name' must be ... and e$param == "value" |
| a computed fieldβs getter | whatever the getter raises (rust_error!(class = ...)) | the computed fieldβs reads |
πWhen to Use Sidecar vs Manual Getters
- Use sidecar when you have fields to expose and want zero-boilerplate
accessors; a
Computedfield covers a value derived from the Rust value. - Use manual getters when a read needs arguments, validation of the receiver beyond the type, or side effects. Manual getters work identically across all class systems and are straightforward to write.
πComparing Objects: all.equal()
Base R canβt look inside a pointer-backed object, so without a method of
the classβs own, all.equal() finds any two objects of one class equal:
all.equal.default() compares an external pointer only by its
data.class(), and an R6 or S4 object leads it to the pointer again.
identical() compares the pointers, so two objects are identical only when
they are the same object. #[derive(AllEqual)] gives the class an
all.equal() method that compares the Rust values field by field (#2149):
#[derive(ExternalPtr, AllEqual)]
#[all_equal(tolerance = 1e-6)] // the default of the method's `tolerance`
pub struct Fit {
#[all_equal(label = "Estimates")]
estimates: Vec<f64>, // all.equal.numeric(), tolerance from the call or 1e-6
#[all_equal(tolerance = 0)]
iterations: i32, // always exact, whatever the call passes
model: ExternalPtr<Model>, // Model's own all.equal() method
#[all_equal(skip)]
cache: RefCell<Option<Vec<f64>>>,
}
#[miniextendr(s3)]
impl Fit { /* ... */ }all.equal(fit_a, fit_b)
#> [1] "Estimates: Mean relative difference: 2e-05"
#> [2] "Component βmodelβ: Component βweightsβ: Mean relative difference: 0.2"
all.equal(fit_a, fit_b, tolerance = 1e-3) # the call's tolerance replaces the struct'sπThe method
The method is all.equal.<Class>(target, current, tolerance, scale, ..., check.attributes, check.names), with its own help page, registered for
the class of the typeβs impl block:
| Class system | Registration |
|---|---|
| env, R6, S3 | S3method(all.equal, Class) |
| S4 | S3method(all.equal, Class): Rβs S3 dispatch finds it for an S4 object, and so does every caller of base::all.equal(), testthat included, which an S4 setMethod() would not reach |
| S7 | S3method(all.equal, Class) and S7::method(all.equal, Class), since an S7 objectβs class attribute is pkg::Class |
A type with the derive but no impl block with a class system, or with a
vctrs class (whose objects are R vectors that all.equal() already
compares), fails to install with a message naming the type.
The method:
- returns baseβs one string,
"target is <class>, current is <class>", whencurrentβsdata.class()differs fromtargetβs; - compares each field with
all.equal(target_field, current_field, ...), so the fieldβs own class picks the method: numbers keep Rβs tolerance, lists and data frames use baseβs methods, and a field holding another pointer classTusesTβs method; - returns
TRUEwhen every field is equal, otherwise every differing fieldβs messages in field order, each prefixed asall.equal.list()prefixes a component (Component βestimatesβ:,Component 1:for a tuple field) or with the fieldβs label (Estimates:).
An object restored from a saved session is refused with the typeβs
miniextendr_restored_* error, on the call as written.
A fieldβs R value is a clone converted with IntoR (so the field is
Clone + IntoR), or:
| Field | Value compared |
|---|---|
#[all_equal(with = "Self::f")] | the getterβs value, fn(&Self) -> impl IntoR |
a Computed field | its #[r_data(get = "...")] getterβs value |
a Sidecar<T> field | its value in the pointerβs protection list |
ExternalPtr<T>, Option<ExternalPtr<T>> | the pointer itself, not a clone (NULL for None) |
the RSidecar selector, #[all_equal(skip)] | not compared |
A field whose type names a pointer class T of the package gets Tβs R
class, as the value of a method returning that type does, so Tβs own
method compares it:
| Field type | R value compared |
|---|---|
T, ExternalPtr<T> | an object of class T |
Option<ExternalPtr<T>> | an object of class T, or NULL |
Vec<T>, Vec<ExternalPtr<T>> | a list of objects of class T, compared by all.equal.list() |
Give T the derive too: without an all.equal() method of its own, T
is compared by all.equal.default(), which finds any two pointers equal.
The derive reads the class from the fieldβs type, so a pointer that a
with getter or a Computed field returns, or a class of another package,
reaches all.equal() as a bare pointer and compares equal to any other
(#2191).
πSettings
tolerance, scale, check_attributes and check_names (the R
arguments check.attributes and check.names) are formals of the method.
- On the struct,
#[all_equal(...)]sets a formalβs default, which the call replaces as any R default. - On a field, it fixes that fieldβs argument: the call canβt change it.
- A setting nothing sets is not passed, so the fieldβs own method keeps its
default, and a nested
AllEqualtype its own struct-level setting.
So a field gets: its own setting, else the callβs, else the structβs, else
its methodβs default. The other arguments of the call reach every field
through .... tolerance and scale come before ..., as in
all.equal.numeric(), so all.equal(a, b, 1e-3) passes a tolerance.
A field also takes skip (alone), label = "..." and with = "Self::f".
An unknown option, skip with another option, a setting of the wrong kind
and a field that is not Clone + IntoR (without with) are compile
errors.
πTesting with testthat
- Edition 2βs
expect_equal()callsall.equal(), so it uses the method. - Edition 3βs
expect_equal()callswaldo::compare(), which never callsall.equal(): it compares an S3, env, S4 or S7 objectβs pointers by address, so two objects are different unless they are the same object, and it finds two R6 objects equal whatever they hold. Test the method withexpect_true(all.equal(a, b)), or withlocal_edition(2). Awaldo::compare_proxy()method for these classes is #2192.
πExport Control
Control R export visibility with #[miniextendr(internal)] and #[miniextendr(noexport)].
These work consistently across standalone functions, class impl blocks, and
trait impls. Both suppress NAMESPACE export() (the function stays callable
via :::); they differ at the documentation level.
π#[miniextendr(internal)]
Adds @keywords internal to roxygen and suppresses @export. The function is
still documented: it contributes an \alias{} and usage entry to its .Rd
page (including shared/grouped pages), which is hidden from the package index
via \keyword{internal} but reachable with ?name.
#[miniextendr(internal)]
pub fn helper_function(x: i32) -> i32 { x * 2 }
#[miniextendr(s3, internal)]
impl InternalType {
pub fn new() -> Self { ... }
}π#[miniextendr(noexport)]
Emits @noRd: no Rd contribution at all β no alias, no usage entry, not
even on a shared/grouped page it would otherwise land on (e.g. a page its
@rdname names, or a classβs @rdname page for methods). ?name finds
nothing. No @keywords internal.
For S3 class impls, noexport also drops the S3method() dispatch
registration from NAMESPACE β zero observable trace. (A hand-written @noRd
doc comment without the noexport attribute keeps dispatch registered:
undocumented but dispatchable.)
#[miniextendr(noexport)]
pub fn private_helper(x: i32) -> i32 { x * 2 }πComparison
| Attribute | @export | @keywords internal | Man page / alias |
|---|---|---|---|
| (default) | Yes | No | Yes |
internal | No | Yes | Yes β alias + usage, hidden from index |
noexport | No | No | No (@noRd) β no alias anywhere |
internal and noexport together on one function is a compile error (on impl
blocks, internal wins).
πS4 Helpers Module
miniextendr provides Rust helpers for interoperating with existing S4 objects (e.g., Bioconductor). These are for reading/writing S4 objects passed as arguments, not for generating S4 classes (use #[miniextendr(s4)] for that).
use miniextendr_api::s4_helpers;
unsafe {
// Check if an object is S4
if s4_helpers::s4_is(obj) {
// Get the class name
let class = s4_helpers::s4_class_name(obj); // Option<String>
// Check and access slots
if s4_helpers::s4_has_slot(obj, "data") {
let data = s4_helpers::s4_get_slot(obj, "data")?; // Result<SEXP, REvalError>
}
// Set a slot value
s4_helpers::s4_set_slot(obj, "label", new_value)?;
}
}| Function | Purpose |
|---|---|
s4_is(obj) | Check if SEXP is an S4 object |
s4_class_name(obj) | Get the S4 class name as Option<String> |
s4_has_slot(obj, name) | Check if a slot exists |
s4_get_slot(obj, name) | Get a slot value as Result<SEXP, REvalError> |
s4_set_slot(obj, name, value) | Set a slot value; Err(REvalError) carries Rβs condition (an invalid value, say) |
All functions require the R main thread and operate on raw SEXP values.
πEnums: value vs instance
A fieldless enum can be exposed to R in two mutually-exclusive ways. Pick one.
By value β the enum crosses the boundary as an R character or factor and is reconstructed on each call; there is no persistent R object holding a Rust instance:
#[derive(MatchArg)]β R character scalar validated withmatch.arg.#[derive(RFactor)]β Rfactorwith integer codes and level labels.
This is the right choice for a closed set of options passed as arguments. See ENUMS_AND_FACTORS.md.
By reference β the enum is wrapped exactly like a struct: #[derive(ExternalPtr)]
plus a class-system impl block. R holds an opaque pointer to a boxed instance,
and the impl blockβs methods become R6/S3/S4/S7/env methods. The variants are
internal state, invisible to R:
#[derive(Clone, Copy, ExternalPtr)]
pub enum DType { F32, F64 }
#[miniextendr(r6)]
impl DType {
pub fn new_f32() -> Self { DType::F32 }
pub fn is_float(&self) -> bool { matches!(self, DType::F32 | DType::F64) }
pub fn size_bytes(&self) -> i32 { match self { DType::F32 => 4, DType::F64 => 8 } }
}
Use this when the enum needs methods or object identity.
πYou cannot have both
Deriving both ExternalPtr and MatchArg/RFactor on one type is a hard
compile error:
error[E0119]: conflicting implementations of trait `IntoR` for type `DType`
= note: conflicting implementation in crate `miniextendr_api`:
- impl<T> IntoR for T where T: IntoExternalPtr;
= note: this error originates in the derive macro `MatchArg`
ExternalPtr supplies a blanket impl<T> IntoR for T where T: IntoExternalPtr,
while MatchArg/RFactor generate their own impl IntoR (and a conflicting
TryFromSexp). A type is therefore either a by-value enum or a by-reference
instance β never both.
If you want both a friendly string API and methods, keep the enum by-value and put the behaviour on a separate wrapper struct (or expose free functions that take the by-value enum).
πRecommendations
- Start with Env for simple cases
- Use R6 when you need encapsulation or active bindings
- Use S3 for tidyverse compatibility
- Use S4 for Bioconductor integration
- Use S7 for new packages wanting modern OOP
When in doubt, start with the default (Env) and migrate if needed.