RZig is most useful when an R
package has a small, computationally expensive loop that needs native
performance but does not otherwise need C++. This vignette ports an
axpy-style operation: return x + scale * y
without modifying either R input. Every step up to the installation runs
in R without a Zig compiler; the installation and the calls run when a
supported compiler is found.
An Rcpp implementation might duplicate x, validate the
inputs, and mutate the duplicate in place:
#include <Rcpp.h>
// [[Rcpp::export]]
Rcpp::NumericVector axpy_rcpp(
Rcpp::NumericVector x,
const Rcpp::NumericVector& y,
double scale
) {
if (x.size() != y.size()) Rcpp::stop("x and y must have equal lengths");
Rcpp::NumericVector result = Rcpp::clone(x);
for (R_xlen_t i = 0; i < result.size(); ++i) {
if (i % 100000 == 0) Rcpp::checkUserInterrupt();
result[i] += scale * y[i];
}
return result;
}The important contract is not the C++ class structure. It is that
y is read only, x is duplicated before
mutation, unequal lengths become an R error, and long computations
remain interruptible.
Set the package path explicitly, then run use_rzig()
once. This disposable example stays inside R’s session temporary
directory; for a real project, replace package_path with
its explicit package root:
package_path <- tempfile("yourpackage-", tmpdir = tempdir())
dir.create(package_path)
writeLines(
c(
"Package: yourpackage",
"Type: Package",
"Title: A Small RZig Example",
"Version: 0.0.1",
"Authors@R: person('Your', 'Name', email = '[email protected]', role = c('aut', 'cre'))",
"Description: Demonstrates a native implementation written in Zig.",
"License: GPL-3",
"Encoding: UTF-8"
),
file.path(package_path, "DESCRIPTION")
)
scaffold <- use_rzig(package_path)
#> Generated RZig bindings for yourpackage: 1 exported function
#> Created RZig scaffold for package yourpackage
scaffold
#> RZig scaffold of package yourpackage: 36 files written
#> Zig source: src/rzig/src/main.zig
#> Exported functions: hello_zig
#> Next steps: edit main.zig, run document(), then install the package with a Zig compiler (see find_zig()).use_rzig() creates the portable build files, the Zig
source tree with the framework, a starter
src/rzig/src/main.zig, and the generated R wrapper for the
starter function. The Zig implementation of the port is shipped with
RZig as inst/examples/axpy.zig; copying it over the starter
file is the only edit the port needs:
main_path <- file.path(package_path, "src", "rzig", "src", "main.zig")
file.copy(system.file("examples", "axpy.zig", package = "rzig"), main_path,
overwrite = TRUE)
[1] TRUE
cat(readLines(main_path), sep = "\n")
const std = @import("std");
const builtin = @import("builtin");
const rzig = @import("rzig");
pub const panic = if (builtin.is_test)
std.debug.FullPanic(std.debug.defaultPanic)
else
rzig.Panic;
/// Compute x + scale * y without changing either R input.
/// @param x The numeric vector to duplicate and update.
/// @param y A borrowed, read-only numeric vector.
/// @param scale The multiplier applied to y.
/// @return A new numeric vector containing the result.
/// @export
pub fn axpy(
x: rzig.Mut([]f64),
y: []const f64,
scale: f64,
) rzig.Error!void {
if (x.data.len != y.len) {
return rzig.raise(
"x and y must have equal lengths; got {d} and {d}",
.{ x.data.len, y.len },
);
}
for (x.data, y, 0..) |*result, value, index| {
if (index % 100_000 == 0) try rzig.checkInterrupt();
result.* += scale * value;
}
}
comptime {
rzig.registerModule(@This());
}rzig.Mut([]f64) is the direct expression of
copy-on-modify semantics. RZig duplicates and protects x
before Zig receives writable storage. The y slice borrows
R’s numeric storage as read-only memory. Because this function returns
void, the generated boundary returns the mutated duplicate
automatically.
The function contains no SEXP, PROTECT, C++
exception, or registration table. The types and @export
marker supply enough information for RZig to generate those parts.
scan_exports() reads the Zig source and lists every
exported function with its R arguments and the R value of each parameter
and of the result:
scan_exports(package_path)
#> Zig exports of package yourpackage (src/rzig/src/main.zig): 1 function
#>
#> axpy(x, y, scale)
#> x rzig.Mut([]f64) double vector, writable duplicate
#> y []const f64 double vector, borrowed
#> scale f64 double, length 1
#> returns rzig.Error!void the writable duplicate, a double vectordocument() writes the Zig manifest, the R wrapper and
the NAMESPACE block. Run it whenever an exported Zig
signature or its documentation changes. The returned object holds the
generated texts:
bindings <- document(package_path)
#> Generated RZig bindings for yourpackage: 1 exported function
cat(bindings$wrappers)
#> # Generated by rzig::document(). Do not edit.
#>
#> #' Compute x + scale * y without changing either R input.
#> #' @param x The numeric vector to duplicate and update.
#> #' @param y A borrowed, read-only numeric vector.
#> #' @param scale The multiplier applied to y.
#> #' @return A new numeric vector containing the result.
#> #'
#> #' @export
#> axpy <- function(x, y, scale) {
#> .Call(axpy_, x, y, scale)
#> }
cat(bindings$namespace)
#> # Generated by rzig::document(): begin
#> useDynLib("yourpackage",
#> axpy_ = "axpy"
#> )
#> export("axpy")
#> # Generated by rzig::document(): endrzig_status() checks every step and tells whether the
generated bindings match the current Zig source and whether a supported
Zig compiler is available for the installation:
rzig_status(package_path)
#> RZig status of package yourpackage
#> [ok] DESCRIPTION package yourpackage
#> [ok] Scaffold build files and framework sources present
#> [ok] Zig source src/rzig/src/main.zig
#> [ok] Exports 1 function: axpy
#> [ok] Bindings generated files match the exports
#> [--] Zig compiler not found; Zig 0.16.x or 0.17.x is needed to install the
#> package
#> [ok] C toolchain C compiler x86_64-linux-gnu-gcc
#> 1 of 7 checks failed: Zig compilerrzig_status() also rejects, before any compilation, the
signatures the boundary cannot convert. A single-precision argument is
one such case:
broken_path <- tempfile("brokenpackage-", tmpdir = tempdir())
dir.create(broken_path)
writeLines(c("Package: brokenpackage", "Version: 0.0.1"),
file.path(broken_path, "DESCRIPTION"))
invisible(use_rzig(broken_path))
#> Generated RZig bindings for brokenpackage: 1 exported function
#> Created RZig scaffold for package brokenpackage
broken_main <- file.path(broken_path, "src", "rzig", "src", "main.zig")
cat("/// @export\npub fn half(x: f32) f32 {\n return x / 2;\n}\n",
file = broken_main, append = TRUE)
rzig_status(broken_path)
#> RZig status of package brokenpackage
#> [ok] DESCRIPTION package brokenpackage
#> R CMD check also requires Title, Description, License,
#> Authors@R
#> [ok] Scaffold build files and framework sources present
#> [ok] Zig source src/rzig/src/main.zig
#> [--] Exports `half`, argument `x`: type f32 is not supported; use f64
#> `half`: return type f32 is not supported
#> [--] Bindings out of date: manifest.zig, R/rzig-wrappers.R, NAMESPACE;
#> run document()
#> [--] Zig compiler not found; Zig 0.16.x or 0.17.x is needed to install the
#> package
#> [ok] C toolchain C compiler x86_64-linux-gnu-gcc
#> 3 of 7 checks failed: Exports, Bindings, Zig compilerbuild_zig() compiles and installs the package from a
copy in the session temporary directory. It runs the checks of
rzig_status() first, keeps the complete installation log,
and on failure reports the first errors of the Zig compiler, the C
compiler, the linker or the load test with their files and lines. The
build needs a Zig compiler of a supported release series. The chunks
below run when find_zig() finds one; this rendered copy of
the vignette skipped them.
build <- build_zig(package_path)
build
package_name <- "yourpackage"
loadNamespace(package_name, lib.loc = build$library)
axpy <- getExportedValue(package_name, "axpy")The generated R function has the same three visible arguments:
x <- c(1, 2, 3)
y <- c(10, 20, 30)
result <- axpy(x, y, 0.5)
x
result
try(axpy(1:2, c(1, 2, 3), 1))
sum(1:10)The native error is an R condition, and the session remains usable.
Integer vectors are rejected for numeric slices and mutable numeric
inputs. Convert deliberately with as.numeric() in R rather
than relying on an implicit native allocation.
Rcpp’s NumericVector combines an R object, indexing,
allocation, and mutation in one C++ class. RZig separates those
capabilities. A borrowed []const f64 cannot mutate its
input. rzig.Mut([]f64) duplicates before exposing writable
storage. rzig.Ctx is used only when Zig-owned scratch or
result memory is needed. Errors are explicit rzig.Error
values, and the outer boundary turns them into R conditions after Zig
cleanup has finished.
Generated packages compile in Zig’s ReleaseSafe mode.
Bounds checks and integer-overflow checks therefore remain enabled in
installed code. A failure is routed through RZig’s panic boundary
instead of silently corrupting adjacent R memory.
Benchmark the algorithm and the language boundary as separate questions. For a hot loop, compare equivalent copy and mutation semantics, use the same compiler optimization level, warm each implementation, randomize execution order, and report distributions rather than one elapsed time.
The RZig repository includes tests/bench.R, which checks
and times equivalent RZig, handwritten C, and Rcpp round-trip copies at
lengths 1, 1,000, and 1,000,000. It records medians and interquartile
ranges from randomized batches. This isolates conversion overhead from
the workload being ported.
Move one native function at a time and keep the surrounding R interface stable. Tests should cover empty and large inputs, missing values, wrong R types, length mismatches, and continued session use after each error. C libraries can coexist with Zig during a gradual conversion. C++ classes and templates usually need to be redesigned as Zig structs and explicit data flow rather than translated line by line.