Reference page
vctrs Integration with `#[derive(Vctrs)]`
miniextendr provides the #[derive(Vctrs)] macro to create vctrs-compatible S3 vector classes from Rust structs. These types integrate seamlessly with the tidyverse ecosystem.
miniextendr provides the #[derive(Vctrs)] macro to create vctrs-compatible S3 vector classes from Rust structs. These types integrate seamlessly with the tidyverse ecosystem.
See also: rpkg/src/rust/vctrs_derive_example.rs (derive examples), rpkg/src/rust/vctrs_class_example.rs (manual approach), rpkg/tests/testthat/test-vctrs-derive.R (tests)
πQuick Start
use miniextendr_api::Vctrs;
#[derive(Vctrs)]
#[vctrs(class = "percent", base = "double", abbr = "%")]
pub struct Percent {
#[vctrs(data)]
values: Vec<f64>,
}
This generates a vctrs vector type that:
- Prints with
%abbreviation in tibbles - Preserves class through subsetting, combining, and operations
- Supports coercion with
vec_ptype2/vec_cast
πVector Types
πSimple Vectors (base = "double", "integer", etc.)
Backed by a single atomic vector.
#[derive(Vctrs)]
#[vctrs(class = "temperature", base = "double", abbr = "Β°C")]
pub struct Temperature {
#[vctrs(data)]
celsius: Vec<f64>,
}
R usage:
t <- new_temperature(c(20.0, 25.0, 30.0))
t[1:2] # Still temperature class
vctrs::vec_c(t, t) # Combines correctlyπRecord Types (base = "record")
Multiple parallel fields, like a data frame row. Each βelementβ of a record vector is a row across all fields. The fields must have equal length.
#[derive(Vctrs)]
#[vctrs(class = "rational", base = "record")]
pub struct Rational {
#[vctrs(data)]
n: Vec<i32>, // numerator
d: Vec<i32>, // denominator
}
R usage:
r <- new_rational(c(1L, 2L), c(2L, 3L)) # 1/2, 2/3
vctrs::field(r, "n") # c(1L, 2L)
format(r) # "1/2", "2/3"
Key behaviors:
- Record vectors have
vctrs_rcrdin their class hierarchy - Fields are accessed with
vctrs::field(x, "name"), notx$name - Subsetting slices all fields in parallel:
x[1:2]givesn[1:2]andd[1:2] - The proxy is a data frame of the fields, enabling vctrs operations
format()is automatically generated, joining field values with/
πList-of Types (base = "list")
Lists where each element has the same prototype. This is vctrsβ way of representing typed lists.
#[derive(Vctrs)]
#[vctrs(class = "int_lists", base = "list", ptype = "integer()")]
pub struct IntLists {
#[vctrs(data)]
lists: Vec<Vec<i32>>,
}
R usage:
x <- new_int_lists(list(1:3, 4:6))
x[[1]] # 1:3 (extract element)
x[1] # Still int_lists class (subset)
vctrs::vec_size(x) # 2
Key behaviors:
- List-of vectors have
vctrs_list_ofin their class hierarchy ptypeattribute records the element prototype (e.g.,integer())x[[i]]extracts the raw element;x[i]preserves the list-of classinherit_basedefaults totruefor list types (required by vctrs)
πWorked Example: Record Type (Rational Numbers)
This section walks through creating a complete record type from scratch.
πStep 1: Define the Struct
use miniextendr_api::Vctrs;
#[derive(Vctrs)]
#[vctrs(class = "derived_rational", base = "record")]
pub struct DerivedRational {
#[vctrs(data)]
n: Vec<i32>, // numerator
d: Vec<i32>, // denominator
}
impl DerivedRational {
pub fn new(n: Vec<i32>, d: Vec<i32>) -> Result<Self, String> {
if n.len() != d.len() {
return Err("n and d must have the same length".to_string());
}
Ok(Self { n, d })
}
}
Fields marked #[vctrs(data)] become the record fields. All #[vctrs(data)] fields must have the same length. You can use #[vctrs(skip)] to exclude a field from the record.
πStep 2: Write a Constructor
use miniextendr_api::{miniextendr, ffi::SEXP};
use miniextendr_api::vctrs::IntoVctrs;
#[miniextendr]
pub fn new_derived_rational(
n: Vec<i32>,
d: Vec<i32>,
) -> Result<SEXP, String> {
let rational = DerivedRational::new(n, d)?;
rational.into_vctrs().map_err(|e| e.to_string())
}
The into_vctrs() method (from the IntoVctrs trait generated by the derive macro) converts the struct into an R object with the correct class, attributes, and structure.
Registration is automatic via #[miniextendr] and #[derive(Vctrs)] β the generated S3 methods (format, proxy, restore, ptype2, cast) are registered via linkme.
πStep 3: Use from R
# Create
r <- new_derived_rational(c(1L, 2L, 3L), c(2L, 3L, 4L))
# Inspect
class(r) #> "derived_rational" "vctrs_rcrd" "vctrs_vctr"
format(r) #> "1/2" "2/3" "3/4"
# Access fields
vctrs::field(r, "n") #> 1 2 3
vctrs::field(r, "d") #> 2 3 4
# Subset (preserves class, slices both fields)
r[2:3] #> derived_rational: 2/3, 3/4
# Combine
vctrs::vec_c(r, r) #> derived_rational: 1/2, 2/3, 3/4, 1/2, 2/3, 3/4
# Prototype (zero-length)
vctrs::vec_ptype(r) #> derived_rational[0]
πWorked Example: List-of Type (Integer Lists)
πStep 1: Define the Struct
#[derive(Vctrs)]
#[vctrs(class = "derived_int_lists", base = "list", ptype = "integer()")]
pub struct DerivedIntLists {
#[vctrs(data)]
lists: Vec<Vec<i32>>,
}
The ptype = "integer()" attribute specifies that each list element must be an integer vector. This is evaluated as an R expression when the class is registered.
πStep 2: Constructor (Accepts R List)
Because the input is a list of integer vectors, accept a raw SEXP and convert manually:
#[miniextendr]
pub fn new_derived_int_lists(
x: SEXP,
) -> Result<SEXP, String> {
use miniextendr_api::from_r::TryFromSexp;
let lists: Vec<Vec<i32>> =
TryFromSexp::try_from_sexp(x).map_err(|e| format!("{:?}", e))?;
let int_lists = DerivedIntLists::new(lists);
int_lists.into_vctrs().map_err(|e| e.to_string())
}πStep 3: Use from R
x <- new_derived_int_lists(list(1:3, 4:6, 7:10))
class(x) #> "derived_int_lists" "vctrs_list_of" "vctrs_vctr" "list"
vctrs::vec_size(x) #> 3
x[[1]] #> 1 2 3
x[[2]] #> 4 5 6
# Subsetting preserves class
x[1:2] #> derived_int_lists with 2 elements
πAttributes Reference
πContainer-Level Attributes
| Attribute | Required | Description |
|---|---|---|
class = "name" | Yes | R class name |
base = "type" | No | Base type: "double", "integer", "logical", "character", "list", "record". Default: "double" |
abbr = "str" | No | Abbreviation for vec_ptype_abbr (shown in tibble headers) |
ptype = "expr" | No | R expression for list-of prototype, e.g., "integer()" |
coerce = "type" | No | Generate coercion methods with this type (can repeat) |
inherit_base | No | Include base type in class vector. Default: true for list/record, false otherwise |
πField-Level Attributes
| Attribute | Description |
|---|---|
#[vctrs(data)] | Mark as the underlying data field (required for IntoVctrs) |
#[vctrs(skip)] | Exclude from record fields |
πAdvanced Feature Attributes
| Attribute | Description |
|---|---|
proxy_equal | Generate vec_proxy_equal for equality testing |
proxy_compare | Generate vec_proxy_compare for comparison/sorting |
proxy_order | Generate vec_proxy_order for ordering |
arith | Generate vec_arith methods for arithmetic operations |
math | Generate vec_math methods for math functions |
πProxy Methods
Control how vctrs compares, sorts, and orders your type.
#[derive(Vctrs)]
#[vctrs(class = "point", base = "record", proxy_equal, proxy_compare, proxy_order)]
pub struct Point {
#[vctrs(data)]
x: Vec<f64>,
y: Vec<f64>,
}
Generated methods:
vec_proxy_equal.point()- Used byvec_equal(),vec_unique()vec_proxy_compare.point()- Used byvec_compare(),sort()vec_proxy_order.point()- Used byvec_order(),order()
For record types, the proxy is a data frame of the fields, enabling lexicographic comparison.
R usage:
p <- new_derived_point(c(1.0, 2.0, 1.0), c(2.0, 3.0, 2.0))
# Equality via proxy_equal
vctrs::vec_equal(p[1], p[3]) #> TRUE (same x and y)
vctrs::vec_equal(p[1], p[2]) #> FALSE (different x and y)πArithmetic Operations
Enable arithmetic with the arith attribute.
#[derive(Vctrs)]
#[vctrs(class = "meter", base = "double", abbr = "m", arith)]
pub struct Meter {
#[vctrs(data)]
values: Vec<f64>,
}
Generated methods:
vec_arith.meter()- Base dispatcher for double dispatchvec_arith.meter.meter()- meter op metervec_arith.meter.numeric()- meter op numericvec_arith.numeric.meter()- numeric op metervec_arith.meter.MISSING()- Unary operations (-x,+x)
R usage:
m <- new_meter(c(1.0, 2.0))
m + m # meter: 2, 4
m * 2 # meter: 2, 4
2 * m # meter: 2, 4
-m # meter: -1, -2
The result preserves the meter class. Operations use vec_arith_base() internally.
πMath Functions
Enable math functions with the math attribute.
#[derive(Vctrs)]
#[vctrs(class = "positive", base = "double", math)]
pub struct Positive {
#[vctrs(data)]
values: Vec<f64>,
}
Generated method:
vec_math.positive()- Handlesabs,sqrt,log,exp, etc.
R usage:
p <- new_positive(c(4.0, 9.0, 16.0))
sqrt(p) # positive: 2, 3, 4
abs(p) # positive: 4, 9, 16
log(p) # positive: 1.39, 2.20, 2.77πCross-Type Coercion
Allow your type to coerce with other types using coerce = "type".
#[derive(Vctrs)]
#[vctrs(class = "percent", base = "double", coerce = "double")]
pub struct Percent {
#[vctrs(data)]
values: Vec<f64>,
}
Generated methods:
vec_ptype2.percent.double()/vec_ptype2.double.percent()- Common type ispercentvec_cast.percent.double()- Cast double to percentvec_cast.double.percent()- Cast percent to double (strips class)
R usage:
p <- new_percent(c(0.25, 0.50))
vctrs::vec_c(p, 0.75) # percent: 0.25, 0.50, 0.75
vctrs::vec_cast(0.5, new_percent(double())) # percent: 0.5πCoercion Design Decisions
When implementing coercion, you choose which type βwinsβ when combining. The coerce = "double" attribute generates βyour type winsβ coercion by default (percent + double = percent). This means:
vec_c(percent, double)returnspercent(double is cast to percent)vec_c(double, percent)returnspercent(double is cast to percent)vec_cast(percent, double())strips the class (returns plain double)
If you need custom coercion logic (e.g., βdouble winsβ where percent + double = double), use the manual approach with #[miniextendr(s3(...))] instead of the derive macro. See rpkg/src/rust/vctrs_class_example.rs for the manual approach.
πMultiple Coercion Targets
You can specify multiple coerce types:
#[derive(Vctrs)]
#[vctrs(class = "celsius", base = "double", coerce = "double", coerce = "integer")]
pub struct Celsius {
#[vctrs(data)]
values: Vec<f64>,
}
This generates coercion methods for both double and integer.
πImpl Block Approach (Rust-Backed Protocol Methods)
For types that need Rust-backed vctrs protocol methods (like a custom format), use the impl block approach with #[miniextendr(vctrs(...))]:
#[derive(miniextendr_api::ExternalPtr)]
pub struct DerivedCurrency {
symbol: String,
amounts: Vec<f64>,
}
#[miniextendr(vctrs(kind = "vctr", base = "double", abbr = "$"))]
impl DerivedCurrency {
pub fn new(symbol: String, amounts: Vec<f64>) -> Self {
DerivedCurrency { symbol, amounts }
}
pub fn symbol(&self) -> String {
self.symbol.clone()
}
/// Rust-backed format method override.
#[miniextendr(vctrs(format))]
pub fn format_currency(&self) -> Vec<String> {
self.amounts
.iter()
.map(|a| format!("{}{:.2}", self.symbol, a))
.collect()
}
}
This gives you the same vctrs class registration but lets you override specific protocol methods with Rust implementations. The format_currency method is called as format.derived_currency() from R.
πDerive vs Manual Approach
miniextendr supports two ways to create vctrs classes:
| Feature | #[derive(Vctrs)] | Manual #[miniextendr(s3(...))] |
|---|---|---|
| Boilerplate | Minimal | Significant |
| Control | Standard patterns | Full flexibility |
| Coercion logic | βYour type winsβ | Any logic |
| Custom format | Via impl block | Direct S3 method |
| Module registration | vctrs StructName; | fn method_name; per method |
Use derive when: You want standard vctrs behavior with minimal code.
Use manual when: You need custom coercion logic, non-standard behavior, or need to work directly with Rβs SEXP values.
The manual approach is demonstrated in rpkg/src/rust/vctrs_class_example.rs where a percent class implements all vctrs methods explicitly using #[miniextendr(s3(generic = "vec_proxy", class = "percent"))] and similar attributes.
πComplete Example
A fully-featured vctrs type with all advanced features:
#[derive(Vctrs)]
#[vctrs(
class = "measurement",
base = "double",
abbr = "msr",
coerce = "double",
arith,
math
)]
pub struct Measurement {
#[vctrs(data)]
values: Vec<f64>,
}
R usage:
m <- new_measurement(c(1.0, 2.0, 3.0))
# Printing
print(m) # <measurement[3]> 1 2 3
tibble::tibble(x = m) # Shows "msr" in header
# Subsetting
m[1:2] # measurement preserved
# Combining
vctrs::vec_c(m, m) # measurement preserved
# Arithmetic
m + m # measurement: 2, 4, 6
m * 2 # measurement: 2, 4, 6
2 * m # measurement: 2, 4, 6
# Math
sqrt(m) # measurement: 1, 1.41, 1.73
abs(-m) # measurement: 1, 2, 3
# Coercion with double
vctrs::vec_c(m, 4.0) # measurement: 1, 2, 3, 4πGenerated R Methods
For a type with all features enabled, the macro generates:
| Method | Purpose |
|---|---|
format.<class>() | String representation |
vec_ptype_abbr.<class>() | Abbreviation (if abbr set) |
vec_ptype_full.<class>() | Full type name |
vec_proxy.<class>() | Underlying data for operations |
vec_restore.<class>() | Restore class after subsetting |
vec_ptype2.<class>.<class>() | Self-coercion prototype |
vec_cast.<class>.<class>() | Self-cast (identity) |
vec_proxy_equal.<class>() | Equality proxy (if proxy_equal) |
vec_proxy_compare.<class>() | Comparison proxy (if proxy_compare) |
vec_proxy_order.<class>() | Ordering proxy (if proxy_order) |
vec_arith.<class>() | Arithmetic dispatcher (if arith) |
vec_arith.<class>.<class>() | Class-class arithmetic |
vec_arith.<class>.numeric() | Class-numeric arithmetic |
vec_arith.numeric.<class>() | Numeric-class arithmetic |
vec_arith.<class>.MISSING() | Unary operations |
vec_math.<class>() | Math functions (if math) |
πvctrs C API Access
miniextendr also exposes the vctrs C API for lower-level operations. This is useful when you need to work with arbitrary R objects and check whether they are vctrs-compatible.
πInitialization
Call init_vctrs() during package initialization (R_init_<pkg>):
use miniextendr_api::vctrs::init_vctrs;
// In R_init_<pkg>:
if let Err(e) = init_vctrs() {
// vctrs not available - OK if we don't need it
}πAvailable Functions
| Function | Signature | Description |
|---|---|---|
obj_is_vector(sexp) | SEXP -> Result<bool> | Check if an object is a vctrs vector |
short_vec_size(sexp) | SEXP -> Result<i32> | Get the size of a vector |
short_vec_recycle(sexp, size) | SEXP, i32 -> Result<SEXP> | Recycle a vector to a given size |
πConstruction Helpers
| Function | Description |
|---|---|
new_vctr(data, class, attrs, inherit_base) | Create a vctrs vctr |
new_rcrd(fields, class) | Create a vctrs record |
new_list_of(data, ptype_or_size, class) | Create a vctrs list_of |
These helpers are used internally by the derive macro but can also be called directly for custom implementations.
πTroubleshooting
πβObject is not a vectorβ Error
This occurs when constructing a vctrs object from invalid data. Check that:
- The base data matches the declared
basetype (e.g.,Vec<f64>forbase = "double") - vctrs has been initialized (via
init_vctrs()inR_init_<pkg>)
πβlist data requires inherit_base_typeβ Error
List-based vctrs types (base = "list") must include "list" in their class vector. The derive macro handles this automatically, but if using new_vctr() directly, pass Some(true) for inherit_base_type or omit it (defaults to true for lists).
πRecord Fields Have Different Lengths
All record fields must have equal length. If construction fails with a length mismatch error, validate field lengths before calling into_vctrs():
if n.len() != d.len() {
return Err("n and d must have the same length".to_string());
}πSubsetting Loses Class
If subsetting (x[1:3]) returns a plain vector instead of preserving your class, ensure:
vec_proxyandvec_restoremethods are registered (the derive macro does this automatically)- The type has
#[derive(Vctrs)]
πCoercion Fails Between Your Type and Another
If vec_c(your_type, double()) errors with βCanβt combineβ, add coerce = "double" to your derive attributes:
#[vctrs(class = "my_type", base = "double", coerce = "double")]πArithmetic Returns Wrong Type
If x + y returns a plain double instead of your type, ensure:
arithis in your#[vctrs(...)]attributes- Both operands are of your type, or one is
numeric - The type has
#[derive(Vctrs)]
πPerformance Notes
- Construction:
into_vctrs()allocates R vectors and sets attributes. For large vectors, the main cost is copying data from Rust to R (bulk memcpy for atomic types). - Record types: Each field is a separate R vector. Record operations (subset, combine) operate on all fields in parallel, so cost scales with the number of fields.
- Proxy/restore overhead: Every vctrs operation (subset, combine, cast) goes through proxy/restore. For simple types, the proxy is just stripping/restoring the class attribute. For records, the proxy creates a data frame.
- Coercion: Cross-type coercion (e.g., percent + double) involves a
vec_castcall per operand. This is the same cost as pure R vctrs classes. - Arithmetic/math: Generated arithmetic dispatches through
vec_arith_base(), which performs element-wise operations on the underlying data. The overhead is the double-dispatch lookup, not the computation itself.
πTips
-
Always mark one field with
#[vctrs(data)]- This is required forIntoVctrsto work. -
Use
abbrfor tibble display - Short abbreviations look better in tibble column headers. -
Record fields are ordered - Field order in the struct determines format output order.
-
Arithmetic preserves class - The result of
meter + meterismeter, notdouble. -
Consider what operations make sense - Not all types should support all arithmetic. A
datemight support subtraction but not multiplication. -
Use
#[vctrs(skip)]for metadata - Fields that arenβt part of the vector data (like a currency symbol) should be skipped. -
Error handling -
into_vctrs()returnsResult<SEXP, VctrsBuildError>. Always handle the error, typically with.map_err(|e| e.to_string()).
πSee Also
- vctrs package documentation
- vctrs S3 vector guide
rpkg/tests/testthat/test-vctrs-derive.R- Derive macro test examplesrpkg/tests/testthat/test-vctrs-api.R- C API test examplesrpkg/src/rust/vctrs_derive_example.rs- Rust derive examplesrpkg/src/rust/vctrs_class_example.rs- Manual approach examplesminiextendr-api/src/vctrs.rs- C API wrappers