Managed data and functions
In the previous section we printed "Hello, World!"
from Julia by evaluating println("Hello, World!")
. While there's a lot we can achieve by using Julia this way, it's inflexible and has many limitations. One of these limitations is that, modulo string formatting and other gnarly work-arounds, we can't change the argument println
is called with.
What we really want to do is call Julia functions with arbitrary arguments. Let's start with println(1)
.
use jlrs::prelude::*;
fn main() {
let handle = Builder::new().start_local().expect("cannot init Julia");
handle.local_scope::<_, 3>(|mut frame| {
let one = Value::new(&mut frame, 1usize);
let println_fn = Module::base(&frame)
.global(&mut frame, "println")
.expect("println not found in Base");
// Safety: calling println with an integer is safe
unsafe { println_fn.call1(&mut frame, one).expect("println threw an exception") };
});
}
The capacity of the frame is set to 3
because &mut frame
is used three times to root managed data.
The first use of the frame happens in the call to Value::new
, which converts data from Rust to Julia. Julia calls this boxing, to avoid confusion with boxing in Rust we'll call it "creating a value" or "converting to managed data" instead. Any type that implements IntoJulia
can be converted to managed data with this function, jlrs provides implementations of this trait for primitive types, pointer types, and tuples with 32 or fewer fields.
Most functions are globals in a module, println
is defined in the Base
module. Julia modules can be accessed via the Module
type, which is a managed type just like Value
. The functions Module::base
and Module::main
provide access to the Base
and Main
modules respectively. These functions take an immutable reference to a frame to prevent them from existing outside a scope, but they don't need to be rooted and this doesn't count as a use of the frame. Globals in Julia modules can be accessed with Module::global
, we use the frame a second time when we call this method to root its result.1
Finally we call println_fn
with the frame and one argument. This is the third and last use of the frame. Any Value
is potentially callable, the Call
trait provides methods to call them with any number of arguments. Specialized methods like Call::call1
exist to call functions with 3 or fewer arguments, Call::call
accepts an arbitrary number of arguments. Every argument must be a Value
.
Calling Julia functions is unsafe for mostly the same reason as evaluating Julia code is, nothing prevents us from calling unsafe_load
with a wild pointer. Other risks involve thread-safety and mutably aliasing data that is directly accessed from Rust, which can't be statically prevented. In practice, most Julia code is as safe to call from Rust as it is from Julia.
One thing that should be noted is that while calling a function is more efficient than evaluating Julia code, each argument is passed as a Value
. This means every function call involves dynamically dispatching to the appropriate method, which can cause significant overhead if we call small functions. In practice it's best to do as much as possible in Julia, and keep the code necessary to call it from Rust as simple as possible. This gives Julia the opportunity to optimize, and we avoid the verbosity of the low-level interfaces jlrs exposes.
All of that said, we didn't want to print 1
, we wanted to print Hello, World!
. If we tried the most obvious thing and replaced 1usize
in the code above with "Hello, World!"
, we'd see that this would fail to compile because &str
doesn't implement IntoJulia
. We need to use another managed type, JuliaString
, which maps to Julia's String
type.
use jlrs::prelude::*;
fn main() {
let handle = Builder::new().start_local().expect("cannot init Julia");
handle.local_scope::<_, 3>(|mut frame| {
let s = JuliaString::new(&mut frame, "Hello, World!").as_value();
let println_fn = Module::base(&frame)
.global(&mut frame, "println")
.expect("println not found in Base");
// Safety: calling println with a string is safe
unsafe { println_fn.call1(&mut frame, s).expect("println threw an exception") };
});
}
So far we've encountered three managed types, Value
, Module
, and JuliaString
, we'll see several more in the future. All managed types implement the Managed
trait and have at least one lifetime that encodes their scope, the method Managed::as_value
can be used to convert managed data to a Value
.
We didn't need to use the frame a second time here, but that's outside the scope of this chapter.