Skip to content

Latest commit

 

History

History
639 lines (492 loc) · 16.3 KB

File metadata and controls

639 lines (492 loc) · 16.3 KB

Struct Mapping with #[derive(JuliaStruct)]

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

Overview

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

Basic Usage

Simple Struct

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

With Clone Support

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

Derive Options

The #[derive(JuliaStruct)] attribute supports additional derive options:

Supported Traits

  • Clone: Enables copy() function in Julia
  • Debug: (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)

Example with Multiple Traits

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 support

Field Access

Automatic Getters and Setters

When #[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.0

Field Type Mapping

Field 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
i8i128, u8u128 Int8Int128, UInt8UInt128
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

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.

Memory Management

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.

Method Binding

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.0

Static Methods

Static 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.0

A 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.

Result and Option Returning Methods

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.

String payloads

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.

Panics

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.

What is not lowered

  • A payload the aggregate cannot carry (Vec<T>, Box<T>, a HashMap, …) keeps the previous behaviour: the method returns the type as written and the manifest reports return_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 returning Self — is boxed before the Result lowering is consulted, so fn new(...) -> Result<Self, E> is not a RustResult.
  • The methods of a generic struct are monomorphized on demand and return the type as written; Result/Option lowering does not apply to them.

Generic objects and registration updates

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.

Best Practices

1. Always Use #[derive(JuliaStruct)]

For structs that you want to use in Julia, always add the attribute:

#[derive(JuliaStruct)]  // ✅ Good
pub struct MyStruct {
    // ...
}

2. Use Clone for Expensive Operations

If you need to copy structs frequently, derive Clone:

#[derive(JuliaStruct, Clone)]  // ✅ Good for copyable structs
pub struct Config {
    // ...
}

3. Keep Structs Simple

Prefer simple field types that map well to Julia:

#[derive(JuliaStruct)]
pub struct Point {
    x: f64,  // ✅ Good: simple type
    y: f64,
}

4. Use Methods for Complex Operations

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
    }
}

Limitations

Current Limitations

  1. Nested structs: Nested structs are not yet fully supported
  2. Complex generics: Very complex generic constraints may not work
  3. Lifetime parameters: Lifetime parameters are not supported
  4. Associated types: Associated types in traits are not supported

Workarounds

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,
}

Examples

Complete Example: 2D Vector

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.

Troubleshooting

Struct Not Found

If you get an error that the struct is not found, make sure:

  1. The struct is marked with pub
  2. The struct has #[derive(JuliaStruct)]
  3. The struct is defined in the rust"" block
// ❌ Bad: missing pub
struct MyStruct { ... }

// ✅ Good
#[derive(JuliaStruct)]
pub struct MyStruct { ... }

Field Access Errors

If field access doesn't work:

  1. Make sure the struct has #[derive(JuliaStruct)]
  2. Check that field names match exactly
  3. Verify field types are supported

Clone Not Working

If copy() doesn't work:

  1. Add Clone to the derive list: #[derive(JuliaStruct, Clone)]
  2. Make sure all fields implement Clone in Rust

See Also