RustCall.jl provides automatic struct mapping through the #[derive(JuliaStruct)] attribute, which allows you to seamlessly use Rust structs as first-class Julia objects.
using RustCall
When you add #[derive(JuliaStruct)] to a Rust struct, RustCall.jl automatically:
- Generates Julia bindings for the struct
- Creates field accessors (getters and setters)
- Generates trait implementations (Clone, Debug, etc.) when requested
- Manages memory lifecycle with automatic finalizers
rust"""
#[derive(JuliaStruct)]
pub struct Point2D {
x: f64,
y: f64,
}
impl Point2D {
pub fn new(x: f64, y: f64) -> Self {
Point2D { x, y }
}
pub fn distance(&self) -> f64 {
(self.x * self.x + self.y * self.y).sqrt()
}
}
"""
# Create a Point2D instance
p = Point2D(3.0, 4.0)
# Access fields directly
println(p.x) # => 3.0
println(p.y) # => 4.0
# Modify fields
p.y = 5.0
println(p.y) # => 5.0
# Call methods
dist = p.distance()
println(dist) # => 5.830951894845301
rust"""
#[derive(JuliaStruct, Clone)]
pub struct PersonInfo {
name: String,
age: i32,
}
impl PersonInfo {
pub fn new(name: String, age: i32) -> Self {
PersonInfo { name, age }
}
pub fn get_name(&self) -> String {
self.name.clone()
}
}
"""
# Create a person
person = PersonInfo("Alice", 30)
println(person.name) # => "Alice"
println(get_name(person)) # => "Alice"
# Clone the person
person2 = copy(person) # Uses Rust's Clone trait
# Both are independent objects
person.age = 31
println(person.age) # => 31
println(person2.age) # => 30
The #[derive(JuliaStruct)] attribute supports additional derive options:
Clone: Enablescopy()function in JuliaDebug: (Reserved for future use)PartialEq: (Reserved for future use)Eq: (Reserved for future use)PartialOrd: (Reserved for future use)Ord: (Reserved for future use)Hash: (Reserved for future use)Default: (Reserved for future use)
rust"""
#[derive(JuliaStruct, Clone)]
pub struct Config {
host: String,
port: i32,
timeout: f64,
}
impl Config {
pub fn new(host: String, port: i32, timeout: f64) -> Self {
Config { host, port, timeout }
}
}
"""
config = Config("localhost", 8080, 30.0)
config2 = copy(config) # Clone supportWhen #[derive(JuliaStruct)] is present, RustCall.jl automatically generates:
- Getters: Access fields using
obj.field_name - Setters: Modify fields using
obj.field_name = value
rust"""
#[derive(JuliaStruct)]
pub struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
pub fn new(width: f64, height: f64) -> Self {
Rectangle { width, height }
}
pub fn area(&self) -> f64 {
self.width * self.height
}
}
"""
rect = Rectangle(10.0, 20.0)
# Get field values
w = rect.width # => 10.0
h = rect.height # => 20.0
# Set field values
rect.width = 15.0
rect.height = 25.0
# Calculate area
rect_area = rect.area() # => 375.0Field types come from the FFI type contract, which is the
same table free functions and methods use — a u16 field and a u16 return
resolve identically. Every Rust primitive is supported, including i128,
u128 and char, plus the std::os::raw aliases and raw pointers:
| Rust Type | Julia Type | Notes |
|---|---|---|
i8 … i128, u8 … u128 |
Int8 … Int128, UInt8 … UInt128 |
|
f32, f64 |
Float32, Float64 |
|
bool |
Bool |
|
usize, isize |
Csize_t, Cssize_t |
|
char |
Char |
The C slot is a UInt32 code point; the value is converted, never reinterpreted |
*const T, *mut T |
Ptr{T} |
Resolved recursively; an opaque pointee degrades to Ptr{Cvoid} |
String |
String |
Read as an owned (ptr, len, cap) buffer and released through <Struct>_free_rust_string |
&str |
String |
Method returns are copied to a Julia string |
A field whose type the contract does not cover raises rather than becoming
Any; see The FFI Type Contract for the full matrix and for
RustCall.FFI_STRICT[].
Generic structs are also supported:
rust"""
#[derive(JuliaStruct)]
pub struct Pair<T> {
first: T,
second: T,
}
impl<T> Pair<T> {
pub fn new(first: T, second: T) -> Self {
Pair { first, second }
}
}
"""
# Create a Pair with Int32
pair_int = Pair{Int32}(10, 20)
println(pair_int.first) # => 10
println(pair_int.second) # => 20
# Create a Pair with Float64
pair_float = Pair{Float64}(3.14, 2.71)
println(pair_float.first) # => 3.14
println(pair_float.second) # => 2.71!!! note
Generic struct support works for examples like Pair{Int32} and Pair{Float64} shown above.
More complex patterns may still need additional testing.
Structs created with #[derive(JuliaStruct)] are automatically managed by Julia's garbage collector. When a Julia object is reclaimed, its corresponding Rust memory is automatically freed.
All pub fn methods in impl blocks are automatically bound:
rust"""
#[derive(JuliaStruct)]
pub struct Calculator {
value: f64,
}
impl Calculator {
pub fn new(value: f64) -> Self {
Calculator { value }
}
pub fn add(&mut self, x: f64) {
self.value += x;
}
pub fn multiply(&mut self, x: f64) {
self.value *= x;
}
pub fn get_value(&self) -> f64 {
self.value
}
pub fn reset(&mut self) {
self.value = 0.0;
}
}
"""
calc = Calculator(10.0)
calc.add(5.0)
calc.multiply(2.0)
println(calc.get_value()) # => 30.0
calc.reset()
println(calc.get_value()) # => 0.0Static methods (methods without self) are supported on #[derive(JuliaStruct)]
types with fields:
rust"""
#[derive(JuliaStruct)]
pub struct MathUtils {
dummy: i32,
}
impl MathUtils {
pub fn new() -> Self {
MathUtils { dummy: 0 }
}
pub fn add(a: f64, b: f64) -> f64 {
a + b
}
pub fn multiply(a: f64, b: f64) -> f64 {
a * b
}
}
"""
# Call static methods with the type first — the Julia spelling of
# `MathUtils::add(3.0, 4.0)`:
result1 = add(MathUtils, 3.0, 4.0) # => 7.0
result2 = multiply(MathUtils, 3.0, 4.0) # => 12.0
# The bare form exists too, as long as nothing else in the block is called
# `add` or `multiply`:
result1 = add(3.0, 4.0) # => 7.0A static method dispatches on the type so that it never shares a method table
with a free #[julia] fn of the same name: MathUtils::add and a free
fn add in one block used to define add(::Any, ::Any) twice and overwrite
each other — silently, or as a hard error when the module is precompiled. When
such a name exists in the block, only the typed form is generated for the
static method and the free function keeps the bare name (#323). The same rule
applies to @rust_crate bindings.
!!! note Unit struct support is still planned for a future release, so the example above uses a regular struct.
A method that returns Result<T, E> or Option<T> is lowered exactly like a
free function (#268): the generated extern "C" wrapper returns a
#[repr(C)] CResult_<Struct>_<method> / COption_<Struct>_<method>
aggregate, and Julia hands you a RustResult{T, E} / RustOption{T}. This is
true on all three paths — an inline rust""" block, @rust_crate, and the
file write_bindings_to_file writes.
rust"""
#[julia]
pub struct Divider {
pub scale: i32,
}
impl Divider {
pub fn new(scale: i32) -> Self { Divider { scale } }
pub fn checked_div(&self, d: i32) -> Result<i32, String> {
if d == 0 {
Err(format!("cannot divide {} by zero", self.scale))
} else {
Ok(self.scale / d)
}
}
pub fn ratio(&self, d: i32) -> Option<f64> {
if d == 0 { None } else { Some(self.scale as f64 / d as f64) }
}
pub fn describe(&self, unit: String) -> Result<String, String> {
if unit.is_empty() {
Err("empty unit".to_string())
} else {
Ok(format!("{} {}", self.scale, unit))
}
}
}
"""
d = Divider(Int32(10))
RustCall.unwrap(checked_div(d, Int32(2))) # => 5
checked_div(d, Int32(0)).value # => "cannot divide 10 by zero"
RustCall.unwrap(ratio(d, Int32(4))) # => 2.5
RustCall.is_none(ratio(d, Int32(0))) # => true
RustCall.unwrap(describe(d, "meters")) # => "10 meters"&mut self methods, static methods and the free-function form all behave the
same way.
A String or &str payload cannot be a field of the C aggregate, so it is
lowered to the same owned buffer a String-returning wrapper already uses:
<owner>_RustCallOwnedString { ptr, len, cap }, released through
<owner>_free_rust_string. The Result lowering and the string lowering
therefore compose, and Result<String, String> works. <owner> is the
struct for an inline method and <Struct>_<method> for a @rust_crate one,
matching the buffer a string-returning method of the same flavour uses.
Julia copies the buffer into a Julia String and releases it immediately,
through the release function resolved in the same generation snapshot as the
call (see Panics and reloads). Only the active payload is decoded
and released; the inactive one is uninitialized on the Rust side.
A &str payload is copied rather than borrowed: unlike a bare &str return,
a payload sits inside an aggregate that outlives the call's temporaries, so one
owned shape keeps the release rule true in every case.
A panic inside a Result/Option-returning method is caught at the boundary
exactly as anywhere else: the wrapper returns the panicked() sentinel (the
Err / None discriminant with no payload initialized), Julia reads the
panic channel before it decodes anything, and raises RustCall.RustPanicError.
- A payload the aggregate cannot carry (
Vec<T>,Box<T>, aHashMap, …) keeps the previous behaviour: the method returns the type as written and the manifest reportsreturn_kind = plain. On a free function the same payload is a compile error, which is the older and stricter rule. - A constructor —
new, or any method returningSelf— is boxed before theResultlowering is consulted, sofn new(...) -> Result<Self, E>is not aRustResult. - The methods of a generic struct are monomorphized on demand and return
the type as written;
Result/Optionlowering does not apply to them.
One generic struct instantiation builds its applicable constructor, destructor, methods and accessors into one library. Each object keeps that library's method snapshots as well as its destructor: replacing the source registration affects new objects, not the layout or method implementation used by an existing object. An existing object's string release and panic channel stay in its original image. Registration prepares all members outside the state lock, then publishes the complete group in one transaction, removing methods absent from the replacement. This does not erase the compiled member snapshots that existing objects own.
A method with stricter trait bounds, or additional method-local type parameters not bound by the struct instantiation, does not prevent construction. Such a method is not part of that object's compiled member set. Calling an unavailable member reports an error rather than silently entering another generation. Calling a method after explicitly finalizing its receiver also reports an error. Methods and field wrappers also reject an object whose captured image has been explicitly closed, even when its Rust allocation pointer is still non-null. Retirement leaves the image mapped and does not disable existing objects.
For structs that you want to use in Julia, always add the attribute:
#[derive(JuliaStruct)] // ✅ Good
pub struct MyStruct {
// ...
}If you need to copy structs frequently, derive Clone:
#[derive(JuliaStruct, Clone)] // ✅ Good for copyable structs
pub struct Config {
// ...
}Prefer simple field types that map well to Julia:
#[derive(JuliaStruct)]
pub struct Point {
x: f64, // ✅ Good: simple type
y: f64,
}For complex operations, use methods instead of exposing internal state:
#[derive(JuliaStruct)]
pub struct BankAccount {
balance: f64,
}
impl BankAccount {
pub fn new(balance: f64) -> Self {
BankAccount { balance }
}
pub fn withdraw(&mut self, amount: f64) -> Result<f64, String> {
if amount > self.balance {
Err("Insufficient funds".to_string())
} else {
self.balance -= amount;
Ok(self.balance)
}
}
pub fn get_balance(&self) -> f64 {
self.balance
}
}- Nested structs: Nested structs are not yet fully supported
- Complex generics: Very complex generic constraints may not work
- Lifetime parameters: Lifetime parameters are not supported
- Associated types: Associated types in traits are not supported
For nested structs, use pointers or references:
#[derive(JuliaStruct)]
pub struct Outer {
inner: *mut Inner, // Use pointer instead of direct nesting
}
#[derive(JuliaStruct)]
pub struct Inner {
value: i32,
}rust"""
#[derive(JuliaStruct, Clone)]
pub struct Vec2DD {
x: f64,
y: f64,
}
impl Vec2DD {
pub fn new(x: f64, y: f64) -> Self {
Vec2DD { x, y }
}
pub fn zero() -> Self {
Vec2DD { x: 0.0, y: 0.0 }
}
pub fn add(&self, other: &Vec2DD) -> Vec2DD {
Vec2DD {
x: self.x + other.x,
y: self.y + other.y,
}
}
pub fn scale(&mut self, factor: f64) {
self.x *= factor;
self.y *= factor;
}
pub fn magnitude(&self) -> f64 {
(self.x * self.x + self.y * self.y).sqrt()
}
pub fn normalize(&mut self) {
let mag = self.magnitude();
if mag > 0.0 {
self.scale(1.0 / mag);
}
}
}
"""
# Create vectors
v1 = Vec2DD(3.0, 4.0)
v2 = Vec2DD(1.0, 2.0)
# Access fields
println("v1: ($(v1.x), $(v1.y))") # => v1: (3.0, 4.0)
# Modify fields
v1.x = 5.0
println("v1: ($(v1.x), $(v1.y))") # => v1: (5.0, 4.0)
# Call methods
v3 = v1.add(v2)
println("v3: ($(v3.x), $(v3.y))") # => v3: (6.0, 6.0)
# Static method
v_zero = Vec2DD.zero()
println("zero: ($(v_zero.x), $(v_zero.y))") # => zero: (0.0, 0.0)
# Clone
v4 = copy(v1)
v4.scale(2.0)
println("v1 magnitude: $(v1.magnitude())") # => v1 magnitude: 6.4031242374328485
println("v4 magnitude: $(v4.magnitude())") # => v4 magnitude: 12.806248474865697!!! note "Future Feature" Methods returning custom struct types are planned for a future release.
If you get an error that the struct is not found, make sure:
- The struct is marked with
pub - The struct has
#[derive(JuliaStruct)] - The struct is defined in the
rust""block
// ❌ Bad: missing pub
struct MyStruct { ... }
// ✅ Good
#[derive(JuliaStruct)]
pub struct MyStruct { ... }If field access doesn't work:
- Make sure the struct has
#[derive(JuliaStruct)] - Check that field names match exactly
- Verify field types are supported
If copy() doesn't work:
- Add
Cloneto the derive list:#[derive(JuliaStruct, Clone)] - Make sure all fields implement
Clonein Rust
- Tutorial: General tutorial on using RustCall.jl
- Examples: More examples of RustCall.jl usage
- API Reference: Complete API documentation
- Generics: Using generics with RustCall.jl