Implement generic struct with different method variants

Hi, I am trying to implement a generic struct NodeValue that has the following properties:

  • hold a generic value of type T
  • provides different versions with different access methods
    • for this simple example just one where the struct implements a send request function and one where it does not
  • I want to store references to objects of this struct NodeValue in a common array where each referenced object may have a different generic value type T and provides different access functions
    • for this, I don't want to use dynamic dispatch but have one enum for each value T variant instead.
    • but each access type is treated the same way, thus the enum just differentiates between T types.

The purpose of this is to be able to define NodeValues with different access rights (for sending values over the network or receiving values), while being able to process a list of all defined NodeValues by simply writing match expressions over the NodeValue type.

The usage would look like this:

let node1: NodeValue<i32, NotWritable> = NodeValue::new(5);
// node.send_write_request();  // should cause a compile error

let node2_writable: NodeValue<f32, Writable> = NodeValue::new(0.1);
node2_writable.send_write_request();  // is ok

// now we can store the references in a common array
let node_refs_enum: Vec<NodeValueRef> = vec![
      NodeValueRef::I32(&node1),
      NodeValueRef::F32(&node2_writable)
];

// do operations of nodes by matching on their type
for node in node_refs_enum {
	match node {
		NodeValueRef::F32(node_ref) => println!("got F32: {}", node_ref.value),
		NodeValueRef::I32(node_ref) => println!("got I32: {}", node_ref.value),
	}
}

I nearly could implement all as I wanted, but I needed to handle each "access-type" by a separate enum. I would like to get rid of this. Does someone know a better/cleaner way of implementing this so I can use the NodeValue struct as described above?

Here is the kind of working version:

pub trait NodeValueWritable {
    fn send_write_request(&self);
}

// Dummy structs
struct NotWritable {}
struct Writable {}
impl NodeValueWritable for Writable {
    fn send_write_request(&self) {}
}


#[derive(Debug)]
pub struct NodeValue<T, ACCESS> {
    pub value: T,
    // other attributes ...
    access_type: PhantomData<ACCESS>
}

impl<T, ACCESS> NodeValue<T, ACCESS> {
    pub fn new(value: T) -> NodeValue<T, ACCESS> {
        return NodeValue{
            value: value,
            access_type: PhantomData
        }
    }
}

impl<T, ACCESS> NodeValueWritable for NodeValue<T, ACCESS> where ACCESS: NodeValueWritable {
    fn send_write_request(&self) {
        // ...
    }
}


/**
 * Wraps NodeValue references with access type parameter.
 * Would like to avoid this!!
 */
pub enum NodeValueRefWithAccess<'a, T> {
    NotWritable(&'a NodeValue<T, NotWritable>),
    Writable(&'a NodeValue<T, Writable>),
}

/**
 * Wraps NodeValue references into an enum to avoid using dynamic dispatch.
 */
pub enum NodeValueRef<'a> {
    F32(NodeValueRefWithAccess<'a, f32>),
    I32(NodeValueRefWithAccess<'a, i32>),
}

For defining and going over a list of NodeValues I always need an inner match expression over the access type, which is totally unnecessary since NodeValue always has the same data members no matter the access type (I guess this also adds some runtime overhead):

// now we can store the references in a common array
let node_refs_enum: Vec<NodeValueRef> = vec![
    NodeValueRef::I32(NodeValueRefWithAccess::NotWritable(&node1)),
    NodeValueRef::F32(NodeValueRefWithAccess::Writable(&node2_writable)),
    // would be nicer to not have to use the NodeValueRefWithAccess enum
];

// do operations of nodes by matching on their type
for node in node_refs_enum {
    match node {
        NodeValueRef::F32(node_ref) => match node_ref {
            // want to avoid this inner match expression
            NodeValueRefWithAccess::NotWritable(node) => println!("got F32: {}", node.value),
            NodeValueRefWithAccess::Writable(node) => println!("got F32: {}", node.value),
        },
        NodeValueRef::I32(node_ref) => match node_ref {
            NodeValueRefWithAccess::NotWritable(node) => println!("got I32: {}", node.value),
            NodeValueRefWithAccess::Writable(node) => println!("got I32: {}", node.value),
        },
    }
}

You could increase ergonomics with some From and Deref implementations.

Incidentally your inline inner match can use the same branch arm, similar to the Deref implementation.

match node_ref {
    NodeValueRefWithAccess::Writable(node) |
    NodeValueRefWithAccess::NotWritable(node) => {
        println!("got F32: {}", node.value);
    }
}

Since you want different methods, you need different types. Since you don't want to avoid dynamic dispatch, you can't type erase your values to something with the same type.

I wouldn't worry about any performance hit from the match without a good reason (measurements indicating a problem). If you're only observing the Ts it probably optimizes out.


What you call a common array is what in Rust is called a Vec.

You may be overusing references. References are typically used for short-term borrows, not long time storage. They may not serve the same use case you're used to "references" serving from another language.

1 Like

Thank you, this looks much cleaner :slight_smile:
I am porting this from c++, I guess this is why I am a bit biased.

I don't know how this could be implemented without references...
I can't use the heap since this should run on a microcontroller (obviously I would then use something else than vec) .

I don't have any advice based on the sample so far, sorry. But feel free to post more questions if you hit a wall...

Maybe you do want what Rust calls arrays [T; N]. They have a fixed length (N).

    let node_refs_enum /* : [NodeValueRef; 2] */ = [
        NodeValueRef::I32(<_>::from(&node1)),
        NodeValueRef::F32(<_>::from(&node2_writable)),
    ];
1 Like

So, here's an enum named XXRef, which means it's immutable based on experience. So none will care whether it's Writable or not.

Maybe spliting it into NodeRef and NodeMut is a better solution.

The drawback is, you cannot mix NodeRef and NodeMut in the same Vec then.
However, maybe we could be benefit from this drawback?

I also implement the same one, which prevent writing on Node without Write access in compile time.

My implementation
use std::{
    any::{Any, TypeId},
    marker::PhantomData,
};

struct R;
struct W;
struct Node<T, Ac>
where
    T: Any,
{
    v: T,
    ac: PhantomData<Ac>,
}

impl<T, Ac> Node<T, Ac>
where
    T: Any,
{
    fn new(v: T) -> Self {
        Self { v, ac: PhantomData }
    }

    fn as_any(&self) -> &dyn Any {
        &self.v
    }

    fn as_any_mut(&mut self) -> &mut dyn Any {
        &mut self.v
    }
}

trait Write {
    fn write(&mut self) {}
}

impl Write for W {}
impl<T, Ac> Write for Node<T, Ac>
where
    T: Any,
    Ac: Write,
{
}

enum NodeRef<'a> {
    I32(&'a i32),
    F32(&'a f32),
}

enum NodeMut<'a> {
    I32(&'a mut i32),
    F32(&'a mut f32),
}

impl<T, Ac> Node<T, Ac>
where
    T: Any,
{
    fn as_node_ref(&self) -> Option<NodeRef<'_>> {
        if self.v.type_id() == TypeId::of::<i32>() {
            Some(NodeRef::I32(self.as_any().downcast_ref::<i32>()?))
        } else if self.v.type_id() == TypeId::of::<f32>() {
            Some(NodeRef::F32(self.as_any().downcast_ref::<f32>()?))
        } else {
            None
        }
    }

    fn as_node_mut(&mut self) -> Option<NodeMut<'_>> {
        if self.v.type_id() == TypeId::of::<i32>() {
            Some(NodeMut::I32(self.as_any_mut().downcast_mut::<i32>()?))
        } else if self.v.type_id() == TypeId::of::<f32>() {
            Some(NodeMut::F32(self.as_any_mut().downcast_mut::<f32>()?))
        } else {
            None
        }
    }
}

fn main() {
    let mut node1: Node<_, R> = Node::new(1);
    // node1.write(); // error: no method named `write` found for struct `Node<_, R>` in the current scope
    let mut node2: Node<_, W> = Node::new(1.);
    node2.write();
    let node_refs = [node1.as_node_ref().unwrap(), node2.as_node_ref().unwrap()];
    for node in node_refs {
        match node {
            NodeRef::I32(v) => println!("{}", v),
            NodeRef::F32(v) => println!("{}", v),
        }
    }
    let node_muts = [node1.as_node_mut().unwrap(), node2.as_node_mut().unwrap()];
    for node in node_muts {
        match node {
            NodeMut::I32(v) => *v = 2,
            NodeMut::F32(v) => *v = 2.,
        }
    }
}
1 Like

Thanks this looks like a good idea.
Actually, Node contains multiple other values I need to access.
But you are right that in my reference array I don't care about the access type.

I adapted my code so that the data of the node is decoupled from the type with the access rights:


/**
 * Data of a node
 */
#[derive(Debug)]
pub struct NodeData<T: Copy> {
    pub value: T,
    pub node_id: NodeId
    // other members
}

impl<T: Copy> NodeData<T> {
    pub fn new(node_id: NodeId, value: T) -> NodeData<T> {
        NodeData {
            value: value,
            node_id: node_id
            // other members
        }
    }
}


#[derive(Debug)]
pub struct Node<T: Copy, ACCESS> {
    node_data: NodeData<T>,
    access_type: PhantomData<ACCESS>
}


pub enum NodeRef<'a> {
    F32(&'a mut NodeData<f32>),
    I32(&'a mut NodeData<i32>),
}

Maybe I can clarify the use case a bit more, here is an example how this is to be supposed to be used by the enduser:

// define values that can be synced over the network
struct MyData {
  pub value_a: Node<i32, Readable>,
  pub value_b: Node<f32, Writable>
}

impl myData {
    pub fn new() -> MyData {
      MyData {
         value_a: Node::new(/*id*/ 0,  /*default value*/ 5),
         value_b: Node::new(/*id*/ 1,  /*default value*/ 10.5),
      }
    }

   // will be autogenerated by a macro
  pub fn get_node_refs(&self) -> [NodeRef; 2] {
     [
       NodeRef::I32(&mut self.value_a.node_data),
       NodeRef::F32(&mut self.value_b.node_data),
     ]
  }
}

// create a server that instantiates MyData and allows to sync the node values over the network
let mut server: Server<MyData> = Server<MyData>::new();

// set a value and send it to someone else
server.data.value_b.set(42)
server.data.value_b.send_value();

// send a read request to other connected clients
server.data.value_a.send_read_request();

// process incoming network packages can change the value of readable nodes
// here the server will make use of the get_node_refs function to search for the node with the id of the incoming package
server.progress_incomming_packages()

// now we can get the changed value
println!("new value_a", server.data.value_a.get())