I can't figure out a good abstraction for my system - all the ones I can think of are flawed in some big way.
This is going to be a long one I'm afraid, so strap in and thank you for reading!
I am writing a game. In this game, the player is challenged to solve various puzzles by placing "devices", connecting them, and programming them (similar to the programming game SHENZHEN I/O).
The devices
Currently, I am thinking of devices like this: a device has a number of ports. Each port is either an input or output port, and you can only connect an input port to an output port and vice versa. Connections between ports just carry single bytes.
A device could be something complicated like a memory component or a full-fledged CPU; or something simple like a signal splitter.
The connected devices are simulated in discrete "ticks", whereby each device performs some logic based on the values of its input ports, and arrives at values for each of its output ports (which go on to affect other devices). Devices can also have an internal state - imagine a "memory" component with "read address", "write enable", "write address", and "write value" ports. At the end of the tick, if "write enable" is nonzero, the value in the "write value" port is stored in memory at location "write address".
I've been working for quite a while now, trying to figure out a satisfying abstraction to allow me to implement this in Rust, but I keep running into problems with every approach I take.
Approach 1: Device
is a trait
My first approach was to make a trait, Device
, with functions for retrieving the names of the input and output ports, providing values to those ports, and attempting to resolve the internal state of the device once all its input ports had had values provided. (I also went down a massively-overkill route in order to optimise the order that the ports should be evaluated in that involved me using petgraph
, a graph data structure library, but I'm pretty sure I'm hugely overcomplicating it by doing that. What can I say, I have a maths background!)
Imagine something like the following:
pub trait Device {
fn input_ports(&self) -> HashSet<String>;
fn output_ports(&self) -> HashSet<String>;
fn output_port_value(&self, port_name: &str) -> Result<u8, Error>;
fn provide_value(&mut self, port_name: &str, port_value: u8) -> Result<(), Error>;
fn resolve_tick(&mut self) -> Result<(), Error>;
}
Problem is, I wrote out a bunch of different devices that implemented this trait, and there's a lot of shared code between them. All of them have a specific hard-coded Vec<String>
of input and output port names, the input_ports()
and output_port()
functions just return those vectors, there's error-checking code to ensure the port names provided in later functions are indeed valid port names... in an object-oriented language, I would probably implement this as an abstract base class, but here in Rust land I feel that I'm supposed to be approaching the problem in a different way.
Approach 2: Device
is a struct
So, I've started seeing if I can implement Device
as a struct instead. Then, my struct can contain the port names, I can consolidate a lot of runtime checks, etc. The different device-behaviours can be implemented by function pointers, or FnMut
s that act on the internal state.
Problem is, since there are such big differences in the internal states of the devices (a memory device has a memory map, a simple splitter would have no internal state at all), I was imagining using a generic struct, like this:
pub struct Device<S> {
state: S,
input_ports: Vec<String>,
output_ports: Vec<String>,
perform_tick: FnMut(S) -> S,
}
(obviously it would be more complicated than that, but this is just an example).
I run into problems here, because my controller object, which sits at a higher level and facilitates communication between the devices, then can't store them:
struct Controller {
devices: Vec<Device>, // Wrong number of type arguments: expected 1, found 0
}
I need to specify the type of the internal state at compile time, but I obviously can't do that because I want to store different devices with different internal states.
It doesn't feel right to make the state then be a trait:
trait State { }
struct Device {
state: Box<dyn State>,
...
}
because
- state is almost by definition just data with no functionality, which is the opposite of what traits are, and
- I need to be able to tie the
Device
's internalFnMut
signature to the same type as whatever the internal state is.
So, it feels wrong for Device
to be a trait, and it feels wrong for it to be a struct. What's left?
Approach 3: Devices aren't real?
This idea is only half-formed, but perhaps I abstract devices away entirely. Perhaps what I actually have is a bunch of ports, and free-floating functions held in some Controller
struct that get executed to mutate arbitrary data. After all, at some point I am going to have to access quite a bit of internal information here... I'm making a game, so I will want to attach quite a bit of other data to these devices - they'll have sprites, the player will be connecting ports together at will, they will want to step through it to debug, so the internal state of each device should be available for inspection... is it possible that I'm making an error by attempting to define this device behaviour completely decoupled from the game engine, and that in fact the engine will have a huge sway over this?
I'm rambling now, but those are my thoughts. I haven't gone into full detail about the devices, so I will attempt to answer questions to clarify extra parts.
Thanks for your help - I've spent a lot of time thinking about this, to no avail!