Module part4_invoking_ocaml_functions_from_rust

Source
Expand description

§Part 4: Invoking OCaml Functions from Rust

The ocaml! macro is used to declare Rust bindings for OCaml functions.

§4.1 The ocaml! Macro

This macro defines Rust function bindings for OCaml functions that have been registered on the OCaml side using Callback.register "ocaml_function_name" ocaml_function_name.

Example:

Suppose you have the following OCaml code:

(* In your OCaml library, e.g., my_ocaml_lib.ml *)
let greet name = Printf.printf "Hello, %s!\n" name
let add_ints a b = a + b

let () =
  Callback.register "ml_greet" greet;
  Callback.register "ml_add_ints" add_ints

To call these from Rust, you would declare them using the ocaml! macro:

mod ocaml_api {
    use ocaml_interop::{ocaml, OCamlInt};

    ocaml! {
        // OCaml: val ml_greet : string -> unit
        // Effective Rust signature generated:
        //   pub fn ml_greet(cr: &mut OCamlRuntime, name: OCamlRef<String>);
        pub fn ml_greet(name: String);

        // OCaml: val ml_add_ints : int -> int -> int
        // Effective Rust signature generated:
        //   pub fn ml_add_ints(cr: &mut OCamlRuntime, a: OCamlRef<OCamlInt>, b: OCamlRef<OCamlInt>) -> BoxRoot<OCamlInt>;
        pub fn ml_add_ints(a: OCamlInt, b: OCamlInt) -> OCamlInt;
    }
}

Key points about the ocaml! macro usage:

  • The macro generates Rust functions (e.g., ocaml_api::ml_greet(cr, ...) and ocaml_api::ml_add_ints(cr, ...)).
  • The first argument to these generated Rust functions is automatically cr: &mut OCamlRuntime, even if not explicitly listed in the ocaml!{} block for a given function signature.
  • Arguments in the ocaml!{} block (e.g., name: String, a: OCamlInt) define the types that the generated Rust function will expect after the cr argument. These are then converted internally to OCamlRef<T> for the actual FFI call.
  • Return types specified in ocaml!{} (e.g., -> OCamlInt) indicate the type contained within the BoxRoot<T> that the generated Rust function will return.

§4.2 Passing Arguments to OCaml

  • The Rust functions generated by ocaml! expect arguments to be passed as OCamlRef<'a, T>.

  • OCamlRef<'a, T> is a reference to an OCaml value. This reference can be derived from an OCaml<'gc, T> or a BoxRoot<T>.

  • Role of the Borrow Checker: ocaml-interop leverages Rust’s borrow checker. An OCaml<'gc, T> value is tied to the lifetime ('gc) of the OCamlRuntime reference (cr) from which it was obtained or with which it is associated. If an operation occurs that mutably borrows cr (e.g., another call into OCaml, allocation), the borrow checker will prevent the use of pre-existing OCaml<'gc, T> values that might have been invalidated by potential GC activity. This is a key safety feature.

  • When to Use BoxRoot<T> for Arguments (and the Borrow Checker): The primary reason for explicitly using BoxRoot<T> for arguments when calling OCaml functions (defined via ocaml!) stems from Rust’s borrow checking rules interacting with the &mut OCamlRuntime requirement of these functions.

    1. The &mut OCamlRuntime Conflict: Functions generated by ocaml! take cr: &mut OCamlRuntime as their first, implicit argument. If you create a temporary, unrooted OCaml value like let ocaml_arg = rust_data.to_ocaml(cr);, this ocaml_arg (of type OCaml<'gc, T>) holds an immutable borrow on cr for its lifetime 'gc. When you then attempt to call an OCaml function, e.g., ocaml_api::some_func(cr, &ocaml_arg), a borrow conflict arises: some_func requires a mutable borrow of cr, but ocaml_arg still immutably borrows it. Rust’s borrow checker will prevent this.

    2. BoxRoot<T> as the Workaround: Converting the Rust data to a BoxRoot<T> using let ocaml_arg_rooted = rust_data.to_boxroot(cr); resolves this. A BoxRoot<T> registers the OCaml value with the GC independently. Its validity is not tied to the specific borrow of cr used for its creation in the same way an OCaml<'gc, T> is. Thus, you can pass &mut OCamlRuntime to the OCaml function and pass &ocaml_arg_rooted (which becomes an OCamlRef<T>) without a borrow checker conflict.

    3. In Practice: This means that for any non-immediate OCaml value (i.e., values allocated on the OCaml heap like strings, lists, custom blocks, etc.) that you intend to pass as an argument to an OCaml function, it generally needs to be in a BoxRoot<T> at the point of the call. The example below illustrates this: val_a_ocaml and val_b_ocaml (representing OCaml floats, which are heap-allocated when boxed) are converted to BoxRoot<OCamlFloat> before ocaml_api::add_floats is called.

    Immediate OCaml values (like OCamlInt, bool, (), or unboxed f64) do not involve OCaml heap pointers in the same way, so they don’t present this specific GC-related borrow checker challenge for their own validity, though the ocaml! macro still ensures they are passed correctly.

OCamlRuntime::with_domain_lock(|cr| {
    let val_a_rust: f64 = 5.5;
    let val_b_rust: f64 = 2.3;

    // Convert to BoxRoot. This is crucial for two main reasons:
    // 1. Borrow Checker: The subsequent call to `ocaml_api::add_floats` requires [`&mut OCamlRuntime`](OCamlRuntime).
    //    If `val_a_rust.to_ocaml(cr)` (which creates an `OCaml<'gc, OCamlFloat>`) were used directly,
    //    it would immutably borrow `cr`. This conflicts with the mutable borrow needed by `add_floats`,
    //    and the borrow checker would prevent the call.
    // 2. GC Safety for Heap Values: `OCamlFloat` (when not unboxed) represents a heap-allocated OCaml float.
    //    Rooting with `to_boxroot(cr)` ensures the OCaml GC doesn't deallocate or move the float value
    //    prematurely, especially if other OCaml operations were to occur.
    // [`BoxRoot<T>`](BoxRoot) makes the OCaml value's lifetime independent of the immediate borrow of `cr` for its creation.
    let val_a_ocaml: BoxRoot<OCamlFloat> = val_a_rust.to_boxroot(cr);
    let val_b_ocaml: BoxRoot<OCamlFloat> = val_b_rust.to_boxroot(cr);

    // Pass references (&) to the BoxRooted values. The ocaml! macro generates
    // functions that take OCamlRef<T>, which can be created from &BoxRoot<T>.
    let sum_ocaml: BoxRoot<OCamlFloat> = ocaml_api::add_floats(cr, &val_a_ocaml, &val_b_ocaml);
    let sum_rust: f64 = sum_ocaml.to_rust(cr);
    println!("Sum of floats from OCaml: {}", sum_rust);
});

§4.3 Receiving Return Values from OCaml

  • Functions declared using ocaml! that return a value will yield a BoxRoot<T>.
  • This ensures that the returned OCaml value is immediately rooted and thus safe for use within Rust.
  • It can subsequently be converted to a Rust type using .to_rust(cr).

§4.4 Handling OCaml Exceptions from Rust

  • If an OCaml function invoked from Rust raises an exception, this will currently manifest as a Rust panic.
  • Recommendation: It is advisable to design OCaml functions intended for FFI with Rust to signal error conditions by returning option or result types (e.g., int option, (string, string) result), rather than by raising exceptions. These can then be mapped to Rust’s Option<T> and Result<T, E> types.