Search code examples
rustborrow-checker

Porting C Code with Mutable Borrows - Error During Runtime (nappgui Example)


I'm transitioning to Rust from a C/C++ background in systems programming. After finishing the official book and Rustlings course, I decided to port the nappgui library (https://github.com/frang75/nappgui) as a way to solidify my Rust understanding.

There's a specific pattern used frequently in nappgui that I'm having trouble translating effectively to Rust. The pattern involves mutable borrows, and while my current implementation compiles, it throws runtime errors related to these borrows.

Here's a minimal example of my final attempt (although it might not perfectly reflect the original nappgui code):

//////////////////////////////////////////////////////////
// Platform specific OSAPP library crate
// e.g. osapp_win.rs

pub struct OSApp {
    abnormal_termination: bool,
    with_run_loop: bool,
}

pub fn init_imp(with_run_loop: bool) -> Box<OSApp> {
    Box::new(OSApp {
        abnormal_termination: false,
        with_run_loop: with_run_loop,
    })
}

pub fn run(app: &OSApp, on_finish_launching: &mut dyn FnMut()) {
    on_finish_launching();

    if app.with_run_loop {
        // Following line commented out to simplify
        // osgui::message_loop();
        i_terminate(app);
    }
}

fn i_terminate(_app: &OSApp) {
    // Calls more client callbacks
}


//////////////////////////////////////////////////////////
// OSAPP crate

use core::f64;
use std::{cell::RefCell, rc::Rc};

struct App {
    osapp: Option<Box<OSApp>>,
    _lframe: f64,
    func_create: FnAppCreate,
}

pub trait ClientObject {}
type FnAppCreate = fn() -> Box<dyn ClientObject>;

pub fn osmain(lframe: f64, func_create: FnAppCreate) {
    let app = Rc::new(RefCell::new(App {
        osapp: None,
        _lframe: lframe,
        func_create: func_create,
    }));

    let osapp: Box<OSApp> = osapp_init(true);

    let tmp_a = app.clone();
    tmp_a.as_ref().borrow_mut().osapp = Some(osapp);

    let tmp_b = app.clone();
    let on_finish_launch = || {
        // I understand why I get the already borrowed mutable error here
        i_OnFinishLaunching(&tmp_b.as_ref().borrow());
        // ^^^^^^^^^^^^^^^^^^^^^^^^^
    };

    let tmp_c = &app.as_ref().borrow_mut().osapp;
    if let Some(osapp) = tmp_c {
        /*osapp::*/
        run(&osapp, &mut &on_finish_launch);
    }
}

fn osapp_init(with_run_loop: bool) -> Box<OSApp> {
    /*osapp::*/
    init_imp(with_run_loop)
}

fn i_OnFinishLaunching(app: &App) {
    (app.func_create)();
}

//////////////////////////////////////////////////////////
// main.rs

struct Application {
    // widgets go here
}

impl ClientObject for Application {}

impl Application {
    fn create() -> Box<dyn ClientObject> {
        let mut app = Box::new(Application {
            // Create all the widgets here
        });

        app
    }
}

fn main() {
    /*osapp::*/
    osmain(0.0, Application::create);
}

Output:

thread 'main' panicked at src/main.rs:55:45:
already mutably borrowed: BorrowError
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

I'd appreciate some guidance on how to redesign or implement the code to avoid these mutable borrow errors. Any insights specific to porting C++ patterns involving mutable borrows to Rust would be especially helpful.

Update

The above is an instance of an architectural pattern used throughout the project. The general pattern in C is as follows:

#include <stdio.h>
#include <stdlib.h>

typedef void(*CB1_T)(void*) ;
typedef void(*CB2_T)(void*) ;

struct LowLevelObject {
    void *listener;
    // state data
    CB1_T callback1;
    CB2_T callback2;
};

void low_api1(struct LowLevelObject *lo)
{
    // some functionality
    lo->callback1(lo->listener);
    // more functionality
}

void low_api2(struct LowLevelObject *lo)
{
    // some functionality
    lo->callback2(lo->listener);
    // more functionality
}

void low_api3(struct LowLevelObject *lo)
{
    // some functionality
    printf("%s\n", __func__);
}


struct LowLevelObject *low_create(
    void *listener,
    CB1_T callback1,
    CB2_T callback2)
{
    struct LowLevelObject *lo = calloc(1, sizeof(struct LowLevelObject));
    lo->listener = listener;
    lo->callback1 = callback1;
    lo->callback2 = callback2;
    
    return lo;
}

void low_destroy(struct LowLevelObject *lo)
{
    free(lo);
}

/////////////////////////////////////////

struct HighLevelObject {
    struct LowLevelObject *low;
    // State data
};

static void on_callback1(struct HighLevelObject *hi)
{
    printf("%s\n", __func__);
    low_api3(hi->low);
}

static void on_callback2(struct HighLevelObject *hi)
{
    printf("%s\n", __func__);
    low_api3(hi->low);
}

struct HighLevelObject *high_create()
{
    struct HighLevelObject *hi = calloc(1, sizeof(struct HighLevelObject));
    
    hi->low = low_create(hi, (CB1_T)on_callback1, (CB2_T)on_callback2);
    // NULL checks ignored for simplicity
    return hi;
}

void high_destroy(struct HighLevelObject *hi)
{
    low_destroy(hi->low);
    free(hi);
}

void hi_start()
{
    struct HighLevelObject *hi = high_create();
    low_api1(hi->low);
    low_api2(hi->low);
    high_destroy(hi);
}

////////////////////////////////

int main() {
    hi_start();
    
    return 0;
}

Solution

  • This is indeed quite hard to model in Rust. Let's analyze what we have here:

    We have two packages, low and high, that depends each on the other, creating a cycle. Because the low package doesn't know about high, they create this cycle with type erasure and cycles in the data structure.

    Rust doesn't like cycles. Really really not. Cycles in data structures are almost impossible to create and manage, and this sort of cycle is no exception. You tried to be clever and avoid the cycle by using composition tree instead of graph, but the cycle is inevitable. You avoided a cycle in the data, but created a cycle in the code flow.

    So, is all hope lost?

    Not at all. We just need to be a bit more clever.

    The solution is to invert the composition.

    You tried to contain the LowLevelObject in the HighLevelObject. That makes sense, conceptually: after all, composition (like inheritance) takes existing types and add functionality to them, so it makes sense to include the "smaller" types in the "bigger" types, no?

    Yes. But sometimes the world forces us to go against logic.

    When we store LowLevelObject in HighLevelObject, we must store the callbacks in the LowLevelObject. We cannot store them in the HighLevelObject, since then the LowLevelObject won't be able to find them. But the callbacks need a reference to the HighLevelObject, and the LowLevelObject cannot give them that by itself, and the HighLevelObject cannot help it because it'll violate the aliasing rules.

    In other words, the callbacks must be stored in the top-level struct. That is because they need access to both the HighLevelObject (directly) and to the LowLevelObject (indirectly, via its methods). The only type that can provide them with that is the type that holds both. But on the other hand, the callbacks cannot be stored in the HighLevelObject, since the LowLevelObject needs to call them. The obvious solution to those requirements is to have the LowLevelObject contain the HighLevelObject.

    Won't this break encapsulation, you ask? This won't, because we will erase the type of the HighLevelObject - via generics, or dynamic dispatch.

    Here's how it will look like:

    // crate `low`
    
    pub struct LowLevelObject<T> {
        pub listener: T,
        callback1: fn(&mut Self),
        callback2: fn(&mut Self),
    
        example_low_level_data: String,
    }
    
    impl<T> LowLevelObject<T> {
        pub fn low_api1(&mut self) {
            // some functionality
            (self.callback1)(self);
            // more functionality
        }
    
        pub fn low_api2(&mut self) {
            // some functionality
            (self.callback2)(self);
            // more functionality
        }
    
        pub fn low_api3(&mut self) {
            // some functionality
            println!("`low_api3()` - {}", self.example_low_level_data);
        }
    
        pub fn create(listener: T, callback1: fn(&mut Self), callback2: fn(&mut Self)) -> Self {
            Self {
                listener,
                callback1,
                callback2,
                example_low_level_data: "this is low-level data".to_owned(),
            }
        }
    }
    
    // crate `high`
    
    pub struct HighLevelState {
        example_high_level_data: String,
    }
    
    pub type HighLevelObject = LowLevelObject<HighLevelState>;
    
    // Define them either as free functions or in extension traits, whatever makes you feel better.
    
    fn on_callback1(hi: &mut HighLevelObject) {
        println!("`on_callback1()` - {}", hi.listener.example_high_level_data);
        hi.low_api3();
    }
    
    fn on_callback2(hi: &mut HighLevelObject) {
        println!("`on_callback2()` - {}", hi.listener.example_high_level_data);
        hi.low_api3();
    }
    
    pub fn high_create() -> HighLevelObject {
        LowLevelObject::create(
            HighLevelState {
                example_high_level_data: "this is high-level data".to_owned(),
            },
            on_callback1,
            on_callback2,
        )
    }
    

    And here's your original code:

    //////////////////////////////////////////////////////////
    // Platform specific OSAPP library crate
    // e.g. osapp_win.rs
    
    pub struct OSApp<App> {
        pub app: App,
        abnormal_termination: bool,
        with_run_loop: bool,
    }
    
    pub fn init_imp<App>(app: App, with_run_loop: bool) -> OSApp<App> {
        OSApp {
            app,
            abnormal_termination: false,
            with_run_loop,
        }
    }
    
    pub fn run<App>(app: &mut OSApp<App>, on_finish_launching: &mut dyn FnMut(&mut OSApp<App>)) {
        on_finish_launching(app);
    
        if app.with_run_loop {
            // Following line commented out to simplify
            // osgui::message_loop();
            i_terminate(app);
        }
    }
    
    fn i_terminate<App>(_app: &OSApp<App>) {
        // Calls more client callbacks
    }
    
    //////////////////////////////////////////////////////////
    // OSAPP crate
    
    use core::f64;
    use std::{cell::RefCell, rc::Rc};
    
    struct AppState {
        _lframe: f64,
        func_create: FnAppCreate,
    }
    
    type App = OSApp<AppState>;
    
    pub trait ClientObject {}
    type FnAppCreate = fn() -> Box<dyn ClientObject>;
    
    pub fn osmain(lframe: f64, func_create: FnAppCreate) {
        let mut app = init_imp(
            AppState {
                _lframe: lframe,
                func_create: func_create,
            },
            true,
        );
    
        let mut on_finish_launch = |app: &mut App| i_OnFinishLaunching(app);
    
        /*osapp::*/
        run(&mut app, &mut on_finish_launch);
    }
    
    fn i_OnFinishLaunching(app: &App) {
        (app.app.func_create)();
    }
    
    //////////////////////////////////////////////////////////
    // main.rs
    
    struct Application {
        // widgets go here
    }
    
    impl ClientObject for Application {}
    
    impl Application {
        fn create() -> Box<dyn ClientObject> {
            let mut app = Box::new(Application {
                // Create all the widgets here
            });
    
            app
        }
    }
    
    fn main() {
        /*osapp::*/
        osmain(0.0, Application::create);
    }
    

    Side note: If you want to store capturing closures in the callbacks, you will face some issues. They are fixable with some tricks, but I don't think you'll need them, since you're porting a C library and C doesn't have closures :)