R strings (CHARSXPs), symbols, and class vectors are immutable once created. When the same value is needed repeatedly — especially on hot paths like vectorized conversions — cache it once and reuse the pointer.

🔗Macros

Two declarative macros in cached_class.rs handle all the boilerplate. Adding a new cached value is a one-liner:

use crate::cached_class::{cached_symbol, cached_strsxp};

// Cache a symbol (Rf_install result):
cached_symbol!(pub(crate) fn tzone_symbol() = c"tzone");

// Cache a single-element class vector:
cached_strsxp!(pub(crate) fn date_class_sexp() = [c"Date"]);

// Cache a multi-element class vector:
cached_strsxp!(pub(crate) fn posixct_class_sexp() = [c"POSIXct", c"POSIXt"]);

// Cache a names vector:
cached_strsxp!(pub(crate) fn error_names_sexp() = [c"error", c"kind", c"call"]);

// With feature gates:
cached_symbol!(
    #[cfg(feature = "vctrs")]
    pub(crate) fn ptype_symbol() = c"ptype"
);

Each macro expands to a function with a static OnceLock<SEXP> inside. First call initializes; subsequent calls are a single atomic load.

🔗cached_symbol!

Caches the result of Rf_install. Symbols are never GC’d, so no R_PreserveObject is needed.

🔗cached_strsxp!

Allocates a STRSXP, fills it with permanent CHARSXPs (via Rf_install + PRINTNAME), and preserves it with R_PreserveObject. Works for class vectors, names vectors, scalar strings — anything that’s a fixed STRSXP.

🔗How it works

🔗Permanent CHARSXPs via symbols

R’s symbol table is never garbage-collected. A symbol’s PRINTNAME is a CHARSXP that lives as long as the R session:

/// Symbol → CHARSXP (never GC'd).
unsafe fn permanent_charsxp(name: &std::ffi::CStr) -> SEXP {
    unsafe { PRINTNAME(Rf_install(name.as_ptr())) }
}

This is cheaper than Rf_mkCharLenCE on repeated calls because it skips the global string hash lookup after the first call (the OnceLock caches the result).

🔗Manual pattern (for reference)

The macros expand to this pattern:

use std::sync::OnceLock;

pub(crate) fn my_class_sexp() -> SEXP {
    static CACHE: OnceLock<SEXP> = OnceLock::new();
    *CACHE.get_or_init(|| unsafe {
        let class = Rf_allocVector(SEXPTYPE::STRSXP, 1);
        R_PreserveObject(class);
        class.set_string_elt(0, permanent_charsxp(c"my_class"));
        class
    })
}

Use the macros instead of writing this by hand.

🔗When to cache

Do cache:

  • Class vectors set on every conversion (c("POSIXct", "POSIXt"), "data.frame", "Date", "factor")
  • Attribute symbols used per-element or per-vector (tzone, mx_raw_type, ptype, size)
  • Names vectors with fixed structure (c("error", "kind", "call"))

Don’t cache:

  • Dynamic strings (user-provided column names, error messages with variable content)
  • One-shot setup code (connection version checks, package init)
  • Strings only used behind a cold if branch

🔗Where caches live

All cached SEXPs are in miniextendr-api/src/cached_class.rs. Feature-gated items use the narrowest #[cfg] that covers their callers:

Cached valueFeature gate
data_frame_class_sexp()(none — always available)
rust_error_class_sexp()(none)
error_names_sexp()(none)
rust_error_attr_symbol()(none)
posixct_class_sexp()any(time, arrow)
date_class_sexp()any(time, arrow)
tzone_symbol()any(time, arrow)
set_posixct_utc()time
utc_tzone_sexp()time
mx_raw_type_symbol()raw_conversions
ptype_symbol()vctrs
size_symbol()vctrs
factor_class_sexp()(none — in factor.rs)

🔗Safety

  • R_PreserveObject prevents GC for the lifetime of the R session. OnceLock ensures single initialization.
  • The cached STRSXP is shared across all callers. This is safe because set_class / Rf_setAttrib attach it as an attribute — they don’t mutate the value itself.
  • Symbols (Rf_install) are never GC’d — no R_PreserveObject needed for the symbol itself, but the accessor caches it to avoid repeated hash lookups.