rhaiscript / rhai

Rhai - An embedded scripting language for Rust.
https://crates.io/crates/rhai
Apache License 2.0
3.73k stars 175 forks source link

Injecting a scope variable into rhai's `global` module #556

Closed ltabis closed 2 years ago

ltabis commented 2 years ago

Hi!

I was looking for a way to create constants that can be injected into a script via rhai::Scope while being accessible to any function. Please checkout the pseudo-code below.

// engine.rs
let engine = rhai::Engine::new();
let mut scope = rhai::Scope::new();

scope.push_constant("STATE", ...);
let ast = engine.compile(script);
engine.call_fn(&mut scope, &ast, "my_func", ()));

// script.rhai

fn print_state() {
  print(STATE.something);
  print(STATE.something_else);
}

// this works.
print(STATE.something);
// this does not.
print_state();

When calling print_state I get: Variable not found: CTX (line 3, position xx) in call to function print_state < anon$ec0bd323da317e6d

I guess the constant in the scope is not declared as global, and therefore is not accessible by any function. Is there a way to set a constant via rust's scope in the global module ?

schungx commented 2 years ago

Rhai functions are pure, so they typically cannot access global variables. That's why they can be taken apart and merged again.

However, this is common enough that you have three alternatives:

1) Use compile_with_scope instead of compile which will use constants in the custom Scope to optimize the script functions; afterwards, you no longer need that Scope

2) https://rhai.rs/book/engine/var.html

3) https://rhai.rs/book/patterns/constants.html (for this way, you can skip the first few steps if your constants are already in your Scope

ltabis commented 2 years ago

Ok so, I can't use your first solution because my use of the engine is shared between threads, has well as the ast, and I need to create a scope on events with a certain state in it. (sorry, my message was missing some context)

For the second solution, it is the same as above, I need to inject the constant at "runtime". I can't use a variable resolver.

Same for the third one.

The option I would go for is this: https://rhai.rs/book/patterns/singleton.html But can you use the singleton in a function without passing it by parameter ? Or is it possible to "re-export" it to the global module ?

schungx commented 2 years ago

Ah. You want different constants for different runs based on the same AST.

Option 1: This is a typical use of variable resolvers. Are you sure you cannot use this?

Option 2: You can "re-optimize" an AST using Engine::optimize_ast which is reasonably efficient. So keep the original AST but re-optimize it based on new constants before each run. As long as you're not doing this millions of times a second that's OK.

Option 3: Engine::call_fn will use constants in your scope when running the named function. It doesn't, however, propagate those into sub calls to other functions though - which is probably what you want. In other words, in your example, accessing STATE within my_func should work, but if my_func calls print_state it won't work.

schungx commented 2 years ago

But can you use the singleton in a function without passing it by parameter ? Or is it possible to "re-export" it to the global module ?

You can definitely use a singleton in a function without passing it by parameter. Just define a function that returns it.

In fact, this is encouraged because that function can easily be changed to add new functionalities without touching any scripts.

For example, you can do:

fn print_state() {
    const STATE = get_current_state();
    print(STATE.something);
    print(STATE.something_else);
}

In that case, all your custom logic resides in the function get_current_state and you don't have to ever worry about scopes, constants etc.

However, it is still a better practice, if a function always acts on a particular source object, to bind that data to the this pointer of the function. For example:

fn print_state() {
    if this.type_of() != "MyState" { throw "BAD BOY!"; }
    print(this.something);
    print(this.something_else);
}
ltabis commented 2 years ago

Option 1: This is a typical use of variable resolvers. Are you sure you cannot use this?

No not really. As I understood a variable resolver does not work in my case because i am working in a multithread environment. I would need to call engine.on_var in every thread, using a mutex on the engine. this is not what I want.

Option 2: You can "re-optimize" an AST using Engine::optimize_ast which is reasonably efficient. So keep the original AST but re-optimize it based on new constants before each run. As long as you're not doing this millions of times a second that's OK.

Re-optimizing the ast in a multi-threaded environment would mean that I have to lock the ast under a mutex right ? If that is the case, this isn't what I want.

Option 3: Engine::call_fn will use constants in your scope when running the named function. It doesn't, however, propagate those into sub calls to other functions though - which is probably what you want. In other words, in your example, accessing STATE within my_func should work, but if my_func calls print_state it won't work.

Ok I did not notice that ! does something like engine.eval propagates the constant into other functions scopes though ?

You can definitely use a singleton in a function without passing it by parameter. Just define a function that returns it.

This seems like the best solution so far ! I'll give it a shot and keep you posted. Thank you for all your propositions !

ltabis commented 2 years ago

Well, actually, I did not find a way to inject a constant injected via a scope in a rhai function, do you known if there is a way to do that with the Singleton Command Object example ? Or just make sure that the singleton can be accessed in any function without passing it by parameters ?

ltabis commented 2 years ago

I could also try this example https://rhai.rs/book/patterns/parallel.html#one-engine-instance-per-call with the variable resolver as you suggested !

schungx commented 2 years ago

Re-optimizing the ast in a multi-threaded environment would mean that I have to lock the ast under a mutex right ? If that is the case, this isn't what I want.

You just have to be more creative than this: Step 1) Clone the unoptimized AST, 2) optimize the copy, 3) run the copy, 4) throw away the copy.

Ok I did not notice that ! does something like engine.eval propagates the constant into other functions scopes though ?

Engine::eval_with_scope does not propagate constants into functions. In fact, even Engine::call_fn doesn't; it just treats that single function as a whole script.

schungx commented 2 years ago

However, judging from the fact that you're conscious about locking, which means that you'd probably want to count your CPU cycles. Therefore, the additional cloning may not be what you want.

In that case, you might really want to think about exposing your API via a set of functions instead of exposing a singleton object.

Or you might want to check out the events handler pattern: which actually I think is what you may be looking for:

https://rhai.rs/book/patterns/events.html

ltabis commented 2 years ago

You just have to be more creative than this: Step 1) Clone the unoptimized AST, 2) optimize the copy, 3) run the copy, 4) throw away the copy.

I'm actually trying to make this work with engine::new_raw & the variable resolver right now, if that does not work, I'll look into this ! i'm just a little bit scared that the ast could be expensive to clone. Thank's again !

In that case, you might really want to think about exposing your API via a set of functions instead of exposing a singleton object.

Unfortunately, I cannot do that. the rhai environment needs to mutate a state that I inject via the scope and that I can get back once the engine evaluation is done.

Or you might want to check out the events handler pattern: which actually I think is what you may be looking for: https://rhai.rs/book/patterns/events.html

Yup I saw that too, I'll take a look into this.

schungx commented 2 years ago

Unfortunately, I cannot do that. the rhai environment needs to mutate a state that I inject via the scope and that I can get back once the engine evaluation is done.

You can easily do that. If that state is a singleton (meaning it is Arc<RwLock<...>> wrapped), then simply have a function that returns it and register the API on top of that object.

let state = get_current_state();
state.property = "new string";
state.update(42);

Sometimes that state is bound to the this pointer that makes it easy to mutate:

fn update(value) {
    this.update(value);
}

STATE.update(42);

Sometimes people break down mutable properties into sets of functions if the API surface is small enough. This can be a very simple style for casual users.

set_current_prop("new string");
update_state(42);
schungx commented 2 years ago

There are multiple ways to access a global state from all functions in the script, and I think that's what you're looking for.

As I have outlined above, they fall under the following categories:

1) Always bind said state to the this pointer

2) Register a function-based API for each action on that state

3) Use a function call to get access to a shared version of the state

ltabis commented 2 years ago

Okay I see where what you mean, this is exactly what I'm looking for. I'll keep you posted ! Thanks again !

By the way, all this conversation is not really an issue, it's just me trying to figure out something with rhai. Should I open a github discussion next time instead of an issue ?

schungx commented 2 years ago

Up to you, they are both OK. Actually, for conversation, it is better to do it in Discord.

ltabis commented 2 years ago

So I've made it all work with the One Engine Instance Per Call pattern & using the Engine::on_var function to inject the data I want from rust in rhai functions like get_current_state and it works perfect!

Unfortunately, I just checked out the 1.7 release and it seems Engine::on_var is deprecated, or at least unstable. Do you known if there is a "stable" way to use the variable resolver in 1.7 ?

schungx commented 2 years ago

It is marked deprecated because it is considered an "advanced" or "volatile" API - meaning that I'm free to change it in the future if the needs arise and you may have to adjust your programs a bit...

Rust doesn't have any way to mark something as "volatile" so "deprecated" is used instead. It is not deprecated in any way,

on_var will always be there in the future, however its "volatile" status mean that I may add new parameters to the callback in the future if needs arise (although that chance may be rare).

If the API is stable for a long time, I'll probably remove the volatile status.

Of course, I'd try very hard not to change API's unless it really adds functionality. For example, I believe the on_var callback changes from &EvalContext to EvalContext which is an owned object which allows you to do more. These sort of things.

ltabis commented 2 years ago

Okay, that seems logical. I'll adapt the code if you feel that the api needs to change.

Anyway, everything is good in my end, I'll close the issue. Thanks again for the help, ill ask my questions in discord from now on. have a great day !