A way to implement "overridable" functions

I’m working on a tool, scriptable in Roto, where I wish to provide a function to the runtime, lets call it score.update():

mod score {
  fn update(category: String, tag: String, score: f32) -> f32;
}

I also want to allow the Roto script to have a score_update function, with the same prototype. The idea is that if score_update exists, then score.update() would call that, otherwise it’d use a default implementation.

The approach I ended up with involves compiling the Roto code twice. I made a type like this:

pub type ScoreUpdateCallback = 
  TypedFunc<roto::Ctx<Ctx>, fn(RotoString, RotoString, f32) -> f32>;

#[derive(Debug, Clone)]
pub struct ScoreUpdate {
    pub ctx: Ctx,
    pub func: Option<ScoreUpdateCallback>,
}

// impl Default for ScoreUpdate { ... }
// impl PartialEq for ScoreUpdate { ... }

impl ScoreUpdate {
    pub fn new(func: Option<ScoreUpdateCallback>) -> Self {
        Self {
            ctx: Ctx::new(),
            func,
        }
    }

    pub fn library() -> Library {
        library! {
            #[clone] type ScoreUpdate = Val<ScoreUpdate>;

            impl Val<ScoreUpdate> {
                fn update(
                    mut self,
                    category: RotoString,
                    tag: RotoString,
                    score: f32,
                ) -> f32 {
                    self
                        .0
                        .func
                        .as_ref()
                        .map_or(
                            score,
                            |func| func.call(&mut self.0.ctx, category, tag, score)
                        )
                }
            }
        }
    }
}

Then, when creating the first Runtime, I do:

let lib = library! {
  include!(ScoreUpdate::library());

  const score: Val<ScoreUpdate> = Val(ScoreUpdate::default());
};

Then compile the script, and pick out the score_update function, and shove it into a ScoreUpdate. Then, I with the second runtime, I can create a similar library, but this time, using the extracted function:

let lib = library! {
  include!(ScoreUpdate::library());

  const score: Val<ScoreUpdate> = Val(score_update);
};

This gets the job done, but has two side effects: score.update() runs with its own context, which is no biggie, it shouldn’t need one. The only reason it has a context in the first place is to let the same script compile for the first pass. The second side effect is the double compilation itself, which also means that any const my script creates, will be created twice. Not a big deal in this case, but a bit wasteful.

So, while this whole thing works, it feels like there should be a better way to achieve the same thing, to provide a default implementation, which the Roto side can override. That, or to have way to create a Rust function that can call a Roto function if that function exists, and provide a default behaviour if it does not.

First class functions would solve this, and I wouldn’t need to compile twice. If I could implement a function on the Rust side that can refer to the runtime context, that’d also work, because then I could extract the function if it exists, and pass the TypedFunc to the context. For example, if I could do something like:

let lib = library! {
  mod score {
    fn update<Ctx>(mut ctx: Ctx, score: f32) -> f32 {
      if let Some(ref func) = ctx.score_update {
        func.call(&mut ctx, score)
      } else {
        score
      }
    }
  }
};

…that’d be huge. Or, well, any other way I could access the context on the Rust side, that’d be neat.

I also considered using channels, but that made my head hurt, and it would have been even messier than the double compilation I ended up with. The same problem could also be solved by requiring the script to declare a const mapping table, and then score.update() would be implementable entirely on the Roto side. I could also make it require the mapping table as an optional param (Option[Map]), but… a declarative mapping table is slightly less powerful. I can’t do score * 2.0 - I can only override. Or encode the operation in the mapping table too, and… lets not go there.

…and that’s about all the ideas I had in my head about this topic. Apologies for the wall of text, and the somewhat rambly post.

I definitely want to do something to allow extracting the context at some point but that is harder to design than it seems. Overridable functions sound like they would appear in other contexts too so that could indeed be interesting.

To try to solve your immediate issue, have you considered making the score a variable in the context? A sketch of the solution would look like this:

struct Score {
    update_fn: Option<TypedFunc<...>>,
}

library! {
    #[clone] type Score = Val<Score>;
    
    impl Val<Score> {
        fn update(self, ...) {
            if let Some(f) = self.update_fn {
                 ...
             } else {
                 ...
             }
        }
    }
}

#[derive(Context)]
struct MyContext {
    pub score: Val<Score>,
}

I think something like that should work and would be relatively nice to use.

I’m still open to implement some of your suggestions though! I think some way to get the context would be great. And maybe overridable functions would be nice too. First class functions are also very much on my todo list.

Disclaimer: I typed this on my phone so I’m omitting some details and I will try this for real tomorrow.

Yep! I realized I can do that after posting, and I ended up with something very similar to your sketch. Same idea, I just “hid” it a little, because I figured it’s better if the hook is used automatically when calling reports.add, than having to call score.update on the Roto side all the time.

The downside of this is that I can no longer embed Ctx into ScoreUpdate, because embedding Ctx in a struct that’s part of Ctx leads to infinite size, and Rust is rightfully unhappy about that. So every time this is called, I create a new dummy context. That’s fine, the costliest part of that is an UUIDv4 and some allocation. But this isn’t as performance critical as iocaine is, so it’s fine.