Howdy!
I'm working on a project that needs to handle state in memory, then persist certain values to disk (as TOML, CSV, etc.), but I'd like the store fields to be flexible/not hard-coded so I can reuse the logic later. Clearly using a struct and dynamic traits is the way forward, I just can't figure out how to populate the store struct with arbitrary fields/types and still benefit from things like IDE integrations.
The best example I can think of (as someone who's mainly dabbled in frontend stuff) are Preact's useSignal()
hooks. You pass in a typed value and the hook returns the signal object, which you then mutate (with the IDE providing proper typechecking, suggestions, etc. as you go). Now, I know that Rust is nothing like TS in many ways, but I'm sure there's a way to allow "arbitrary" types to fill in a struct while still providing proper IDE integration (not just showing Vec<Box<dyn StoreField>>
but the actual passed types as options when autocompleting/typechecking code).
Here's where I'm at code-wise:
// local store for various config/state/etc data
pub trait StoreField {
// get the field's value
fn get(&self);
// set/override the field's value
fn set(&self);
// initialize the field in memory
fn init(&self);
}
pub struct Store {
// store state
pub state: Box<dyn StoreField>, // only one field though... I could use a Vec but then I couldn't access fields directly by name
}
And here's a rough example of what I'm trying to do:
#[derive(StoreData)] // some proc macro that I'll eventually write to recursively implement `StoreField` for each field
struct AppState {
foo: String,
bar: bool,
}
fn example() {
let store_instance = Store::new(AppState::default()); // again, i'll implement `new()` and `Default` later
store_instance.state.bar.set(false);
assert!(store_instance.state.bar.get(), false);
}
Cheers!
Why so much complexity just to avoid static typing? The trait looks similar to AnyMap
, but IDEs can't really tell you anything about what you can put into it (because you can add any type) and they can't tell you which types are used because that's only defined at runtime.
You normally do this in Rust the other way around; it's the serialization that's abstracted away.
// Your proc_macro already exists in serde...
#[derive(Deserialize, Serialize)]
struct AppState {
foo: String,
bar: bool,
}
// Or serde_toml, _csv, etc...
let state: AppState = serde_json::from_slice(
&fs::read(state_path)?
)?;
I'm probably going about this in the wrong way honestly, fighting against Rust's type system, hence the complexity. I'm doing this mainly for developer experience, though re-usability is also important as this project has multiple different components and I'd rather not duplicate and re-type the store code for each separate crate/app. I'm referencing Preact/TypeScript just to demonstrate the DX and flexibility I'm trying to achieve based on my past experience. I don't need to swap out types at run-time or do anything special that couldn't be handled at compile-time.
Hmm, I was already planning on using serde for the disk/persistence logic, but I didn't know it handled types like this as well. Thanks for the tip!
Could I wrap this in a struct that implements some getter/setter functions, or would that make Rust's memory management/ownership mad? This way I won't have to repeat the serde snippets whenever I want to interact with state.
Sure, but you can just add an impl block with whatever APIs you want too:
impl AppState {
fn new() -> Self { ... } // Or just derive Default
fn read_toml(path: &Path) -> Result<Self> {
...
}
...
}
There's functionally not really much difference between inherent methods and free functions, but it makes things a bit easier to find and name given the conventions that exist for this sort of thing.
It's very unconventional and generally harmful to use getters and setters, but there's occasionally good uses. The main thing to be aware of is they force a borrow of the entire type, so if you return a reference to some nested structure you can't update any other fields. You instead normally control access using the module system and visibility; but that's a little big a topic for here.
Ah I see, I've clearly been "over-layering" or nesting things unnecessarily. I agree with you about getter/setter paradigms, however I can't think of a better way to implement consistent reads and writes. What are the common conventions in Rust for these sorts of scenarios?