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_rcrd in their class hierarchy
  • Fields are accessed with vctrs::field(x, "name"), not x$name
  • Subsetting slices all fields in parallel: x[1:2] gives n[1:2] and d[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_of in their class hierarchy
  • ptype attribute records the element prototype (e.g., integer())
  • x[[i]] extracts the raw element; x[i] preserves the list-of class
  • inherit_base defaults to true for 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

AttributeRequiredDescription
class = "name"YesR class name
base = "type"NoBase type: "double", "integer", "logical", "character", "list", "record". Default: "double"
abbr = "str"NoAbbreviation for vec_ptype_abbr (shown in tibble headers)
ptype = "expr"NoR expression for list-of prototype, e.g., "integer()"
coerce = "type"NoGenerate coercion methods with this type (can repeat)
inherit_baseNoInclude base type in class vector. Default: true for list/record, false otherwise

πŸ”—Field-Level Attributes

AttributeDescription
#[vctrs(data)]Mark as the underlying data field (required for IntoVctrs)
#[vctrs(skip)]Exclude from record fields

πŸ”—Advanced Feature Attributes

AttributeDescription
proxy_equalGenerate vec_proxy_equal for equality testing
proxy_compareGenerate vec_proxy_compare for comparison/sorting
proxy_orderGenerate vec_proxy_order for ordering
arithGenerate vec_arith methods for arithmetic operations
mathGenerate 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 by vec_equal(), vec_unique()
  • vec_proxy_compare.point() - Used by vec_compare(), sort()
  • vec_proxy_order.point() - Used by vec_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 dispatch
  • vec_arith.meter.meter() - meter op meter
  • vec_arith.meter.numeric() - meter op numeric
  • vec_arith.numeric.meter() - numeric op meter
  • vec_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() - Handles abs, 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 is percent
  • vec_cast.percent.double() - Cast double to percent
  • vec_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) returns percent (double is cast to percent)
  • vec_c(double, percent) returns percent (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(...))]
BoilerplateMinimalSignificant
ControlStandard patternsFull flexibility
Coercion logicβ€œYour type wins”Any logic
Custom formatVia impl blockDirect S3 method
Module registrationvctrs 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:

MethodPurpose
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

FunctionSignatureDescription
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

FunctionDescription
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 base type (e.g., Vec<f64> for base = "double")
  • vctrs has been initialized (via init_vctrs() in R_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_proxy and vec_restore methods 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:

  • arith is 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_cast call 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

  1. Always mark one field with #[vctrs(data)] - This is required for IntoVctrs to work.

  2. Use abbr for tibble display - Short abbreviations look better in tibble column headers.

  3. Record fields are ordered - Field order in the struct determines format output order.

  4. Arithmetic preserves class - The result of meter + meter is meter, not double.

  5. Consider what operations make sense - Not all types should support all arithmetic. A date might support subtraction but not multiplication.

  6. Use #[vctrs(skip)] for metadata - Fields that aren’t part of the vector data (like a currency symbol) should be skipped.

  7. Error handling - into_vctrs() returns Result<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 examples
  • rpkg/tests/testthat/test-vctrs-api.R - C API test examples
  • rpkg/src/rust/vctrs_derive_example.rs - Rust derive examples
  • rpkg/src/rust/vctrs_class_example.rs - Manual approach examples
  • miniextendr-api/src/vctrs.rs - C API wrappers