Help with handling state

I'm doing my first steps with Rust, after being mostly a frontend developer for years. So, obviously I find myself trying to do stuff as I would do it with JavaScript, and that's not good! I'm porting a text based game I did with JS to Rust. I've been able to code most of the features, but I feel the code could be much better, especially when it comes to handling the game state.

For example, the player can craft items. If the player wants to craft cooked meat, they need to have raw_meat in their inventory, as well as a fire burning in their camp. To check if all the requirements are met, I have an inventory and fire defined in my main function, and I pass those to the craft function. Obviously, this is not very scalable because I end up passing too many arguments around. One easy way to improve it would be grouping all of those in a single state (like I would do with javascript) and just pass that single one around, but what do you think would be the most rustacean approach to solve a problem like this?

To be entirely honest, the way I write things does involve passing lots of arguments. I may group together some things that are closely related (especially if I want to protect some class invariant), but I still deal with many arguments (often 4-9).

In other languages like JavaScript, when you make a large object with lots of fields and pass it around, you can end up with lots of tricky things like fields that need to begin uninitialized; or places where a function receives both the state and a closure, but calling the closure modifies the state.

When you try to model this in rust, suddenly all of those late-initialized fields need to explicitly become Options. And the borrow checker will throw a fit in the closure example.

In the end, I think it is for the better. Most of those situations were tricky to reason about and common sources of bugs. More arguments is noisier, but I believe it is easier to maintain.

1 Like

You could do it the way you like in rust - passing a large State struct… I think…

In javascript if you don't initialize a field it is undefined, kind of like None if you think about it. It's just that, in javascript, you can reference stuff and assume they are not undefined, so you don't need to explicitly handle the case where it is undefined. When you actually use an undefined value you get a exception. [0]

Rust forces you to be more explicit about that, but you can still use .unwrap() to "assume" a Some value, and just like javascript, it panics if it is actually a None.

[0]: By use I mean stuff.func() or stuff.prop.

If you aren't already doing so, you can try to group the states logically into sub-structs, so you don't end up having to write lots of Option<i32>s. For example, let say if you have camp_fire and camp_size, and they are only initialized once the player found a camp or something. You can group then into a standalone struct CampState and have camp: Option<CampState> in your State struct. This is because these values usually get initialized together.

If you have the closure receive state as an argument like |state: &mut State|, I don't see how it might cause a borrow checker problem… Perhaps, if you want to hear my suggestion, you could provide a code example…?

I am referring to when it doesn't take it as an argument (because it doesn't need to in JS):

for_each_water_tile(
    &state,
    |pos| state.add_entity(Entity::Swan, pos),
)

To make rust happy with this, the function cannot possibly take &State:

for_each_water_tile(
    &state.grid,
    {
        let entities = &mut state.entities;
        |pos| entities.insert_at(pos, Entity::Swan),
    },
)

I see what you're saying…

The borrow checker is complaining because it can't be sure that for_each_water_tile won't hold a reference to State as the closure is running. One solution would be to pass state.grid rather than state to for_each_water_tile, as you said. Another, if you feel like it, would be to use RefCell to dynamically check borrow rules. i.e.:

let st_cell = RefCell::new(state);
for_each_water_tile(&st_cell, |pos| {st_cell.borrow_mut().entities.insert(pos, /* ... */); });

meanwhile,

fn for_each_water_tile<F: Fn(usize)>(state: &RefCell<State>, closure: F) {
	let len = state.borrow().grid.len(); // so that we don't hold a reference to state when closure is being called
	for i in 0..len {
		closure(i);
	}
}

It is also possible to make for_each_water_tile works for both State inside RefCell and &State by changing the function to accept a Borrow<State>.

Now that I thought about it more, that RefCell solution actually feels really ugly. I don't know whether I'm suggesting the right thing - please don't take my RefCell suggestion seriously.

Maybe this is just a limitation of rust that we'll have to work with… Either don't pass the whole struct - just pass what is needed, or use unsafe.


( Actually, is it a "limitation"? because other safe languages aren't totally "safe" either - let's say we wrote this in javascript:

for_each_water_tile(state, pos => { /* change state.grid */ });

One may call the result "undefined behavior", and the function caller just has to promise not to change state.grid in the closure…

unsafe does not permit you to do things that Rust disallows; unsafe only permits you to tell the borrow checker that you have taken on the responsibility of proving correctness in situations that are too complex for the borrow checker itself to make such proofs. Anytime you do things that Rust disallows, you are probably UB, and thus have given the compiler permission to optimize your code to do things that you did not intend. Therein lie both incorrect results and vulnerabilities to malware.

5 Likes

I'm thinking about using it like this:

let the_same_state = unsafe {&mut *(&mut state as *mut _)};
for_each_water_tile(&state, |pos| {the_same_state.entities.insert(pos, /* ... */); });

And being extra careful about not touching state.grid in the closure… Although I would agree this is probably a flawed approach, but at least if one does it right, no UB.

My understanding is that the leading consensus is that your example is undefined behavior. Even ephemeral borrows (such as &thing as *const T)[1] are considered to be instant UB in the presence of a &mut T, provided that the &mut T is still active (i.e. it is later used).

(somebody please correct me if I am wrong!)

Granted, if only disjoint fields of object are used, I doubt that I can construct an example which will actually behave contrary to the programmer's expectation in the current compiler.. But as an example, a future compiler theoretically could detect the aliasing and assume that the code is unreachable for optimization purposes.



  1. I'm not saying that this is an ephemeral borrow (it obviously isn't). I just mean, ephemeral borrows seem even more innocent than this code, yet they are still UB. ↩︎

1 Like

Please just use a RefCell instead of this scary unsafe code.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.