Keeping things in collections without forgetting their types

I am taking a Rust course, and there is a home assignment I got stuck on.

We are creating a "smart home" library, which should have an entity for House, an entity for Room and an interface for a smart device. The user should be able to create an instance of a house, add an arbitrary number of rooms to it, implement the interface for smart devices and add any number of those to the rooms. The house should provide a possibility to generate reports from all devices in all the rooms, and the user must be able to interact with all the devices in all the rooms.

My first approach was to make the House to store a vec of rooms, and then the rooms store vecs of devices

pub struct House<'a> {
    pub name: String,
    pub rooms: Vec<&'a Room<'a>>,
}

pub struct Room<'a> {
    pub name: String,
    pub devices: Vec<&'a dyn Device>,
}

pub trait Device: Named + Report {}

The problem with that approach is that when I implement the Device trait and add instances of those devices to the rooms, when I get them back, their type is unknown. I know it is possible to look at their TypeIDs and then cast them, but I don't want to do this just yet.

Then I tried another approach:
Lets implement a DeviceStorage<T: Device>. Whenever you add a thing to it, it returns an UUID, and then you can store that UUID in a room. And then, when we want to generate a report, we will be able to pass all the device-specific storages to a house, the house will pass them down to the rooms, the rooms will filter the devices in those storages by the UUIDs those rooms hold and make the required reports. That way we will always know the type of those devices because they are stored in generic storages.

pub struct DeviceStorage<T: Device> {
    pub devices: HashMap<uuid::Uuid, T>,
    pub devices_by_name: HashMap<String, uuid::Uuid>,
}

impl<T: Device> DeviceStorage<T> {
    pub fn add(&mut self, device: T) -> uuid::Uuid {
        let s = uuid::Uuid::new_v4();
        self.devices_by_name.insert(device.name().clone(), s);
        self.devices.insert(s, device);
        s
    }
    pub fn by_uuids(&self, uuids: &[uuid::Uuid]) -> Vec<&T> {
        self.devices
            .iter()
            .filter(move |(uuid, _)| uuids.contains(uuid))
            .map(|(_, socket)| socket)
            .collect::<Vec<&T>>()
    }
}

fn main() {
    let mut sockets = p2::DeviceStorage::<PlugSocket>::new();
    let uuid1 = sockets.add(PlugSocket::new_grid_socket("one"));
    let uuid2 = sockets.add(PlugSocket::new_grid_socket("two"));

    let mut house = p2_locs::House::new("house of the rising sun");
    house.devices.push(uuid1);
    house.devices.push(uuid2);
}

Again, the next step in my plan was to put such device storages in a vector and pass them to the house to generate a report. However, the problem now is that I cannot make a vector of these device storage instances because they are of different types:

let my_vec: Vec<&DeviceStorage<&dyn Device>> = vec![&sockets, &thermos];
---
the trait bound `&dyn another_rust_thing::api::Device: another_rust_thing::api::Device` is not satisfied
the following other types implement trait `another_rust_thing::api::Device`:
  another_rust_thing::devices::PlugSocket
  another_rust_thing::devices::Thermometer

Is there a proper approach for doing this (I mean, storing things in collections of collections and still keeping track of their types)? Or is there a proper way of declaring that vector of DeviceStorages that would work?

1 Like

You could define Room as follows:

pub struct Room {
    pub name: String,
    pub devices: Vec<Box<dyn Device>>,
}

That would allow different device types to exist in a single Vec as they would all be behind a Box pointer. The Room would also have ownership over the Devices, so there's no need for an explicit lifetime.

4 Likes

Yes, but whenever I get a device from that vector, I will not know whether it is a socket, a thermometer or anything else

Without going through Any, there's no way to recover the concrete type of a trait object. If you have a closed set of device types, you can use an enum instead.

Alternatively, you can have both an Arc<ConcreteType> and an Arc<dyn Trait> that point to the same object (see below). This means that you can store Arc<dyn Device> inside a Room while the code that generated the device in the first place still has access to a non-type-erased reference.

use std::fmt::Debug;

#[derive(Debug)] struct DeviceA { x: u32 }
#[derive(Debug)] struct DeviceB { name: String }

fn main() {
    use std::sync::Arc;
    
    let a = Arc::new(DeviceA { x: 42 });
    let b = Arc::new(DeviceB { name: String::from("B") });

    let reports: Vec<Arc<dyn Debug>> = vec![a.clone(), b.clone()];

    dbg!(&reports);

    dbg!(a.x);
    dbg!(b.name.as_str());
}
4 Likes

You could call a get_device_type() method that all Devices must define.

1 Like

Either make Device an enum or design all requirements as methods of Device, so you never have to actually know the concrete type (which is considered an anti-pattern in many OOP languages).

7 Likes

Without understanding exactly what you want to do it sounds to me that you have something, a device, that can be any one of a number of device types socket, thermometer, etc, etc. No matter where device came from, a vector element a function parameter, etc. This sound like device is best represented by an enum.

Maybe this is a helpful read for you: "Polymorphism in Rust: Enums vs Traits": https://www.mattkennedy.io/blog/rust_polymorphism/#

Also, perhaps you can make your traits into enums with https://crates.io/crates/enum_dispatch

3 Likes

We were tasked with designing a library, and the users of that library must be able to develop their own devices with their own functionality. This is why I am trying to use a trait for devices. If I use an Enum, all the device types will be hardcoded into the lib and it will not be extendible

1 Like

What I'm also thinking (and will try to do) is reverse the relations between the devices and rooms, so that rooms don't know which devices are in them, but devices know which rooms they are in, and then we may use some sort of a visitor or something to generate the reports

Was this assignment explicitly written for rust? And can you share some example code that shows what you want to be able to do with the specific devices?

I'm not 100% sure that it is a good (or at least, timely) assignment, but it was given to us as part of a Rust course. To be fair, I already did get a passing mark for it with the first approach, where the type of a device is lost, but I want to explore more. I push my code here: https://github.com/ibolit/another_rust_thing/tree/device_storages/1.04/src

As for knowing concrete types at runtime being an antipattern in OOP languages — I'm not sure about that. I think, the key is knowing just enough to do the task, that is, if I need to generate reports, I need to know that the thing I'm dealing with can generate reports, but if I want to switch on a light bulb, I need to know that it is a lightbulb. Moreover, I was quite impressed by Let's Get Rusty's video on YouTube where Bogdan explained that you can have different types for different state of the same thing, like, LightBulb<On> and LightBulb<Off> having different methods on them, so you can't even compile the code that tries to use a closed db connection, for example. But I might be biting off a bit too much )

Ah good, you are not sure. That means you are not lost yet!

First of all OOP is regarded my an increasing many as a really bad idea. See: Object-Oriented Programming is Bad - YouTube. Of course once you have seen that YouTube will suggest many other videos pointing out the problems with OOP.

Secondly, this is all despite the narrative taught in many classes for the last 30 years or so. And despite the endless lecturing by self proclaimed OOP gurus and the like. For sure listen to their advice but don't assume it is the best way to go everywhere. Always look out for the down sides in understandability, performance or whatever you value most.

I seriously think the famous "Gang of Four" patterns are mostly suggestions to OPP programmers on how to work around the OOP paradigm they have locked themselves into.

Anyway, at the end of the day, for whatever reason, Rust is not an OOP language and using it as if it were is likely to lead to problems.

Yes indeed. Obviously if you want to use a thing to it's full you have to have access to all it's features. Clearly putting traits on a set of things and dynamically passing those around hides access to the things features that are not exposed in the traits. That may be OK in some cases.

Passing an enum around that can be any of your set of things may well be what you need in other cases.

I think Bogans LightBulb<On> and LightBulb<Off> style can be great for building state machines. It does not look like the problem you started this thread with.

Indeed. Of course there is a third option:

Seems to me that thermometers or coffee machines or whatever devices don't care what room they are in and don't need to know that for their correct operation. It feels like tangling things up to put the room information into the device.

Similarly a room does not really care about what devices are in it. My living room works just as well when I remove my laptop! (Perhaps windows, doors and other items are more critical to a room).

So what to do? One could have the information as to what room a device is in, or conversely what room contains what devices, in some other place that is not a room or a device. Some kind of table that relates devices to rooms and/or vice versa. Given a room one could look up what devices are there. Or given a device one could look up what room it is in.

Of course then rooms and devices would need to be identified some how. Perhaps by some smart pointer reference or some id that could be used to look them up.

What I'm getting at is that we might naturally think about a room as a container for devices. But while that is true in the physical world it need not be in the representation in our program. The physical room as a container is not the same kind of container as an array or hash map or even struct.

4 Likes

I switched to Python a number of years ago after a few years of programming in Java, and I did come to the same conclusion. But then again, Rust seems to have a significantly stricter type system than Java, and I want to understand whether it is something that can be taken advantage of or something to work around. The idea that if something compiles it proves that it is correct, is an interesting idea. I do know from my own experience that having too many types that are too specific can be a very serious problem, and so I want to get a good understanding of Rust's type system.

This is a valid point, but up to a point. We are not developing an abstract house that can contain anything, we are constructing a framework for working with a particular set of devices. In this way, we can think of a Room as a table relating the name of an actual room in the house to a set of devices that are located there, or we can think of devices as meaningless unless they are in a particular room, so a room can be just another attribute on a device.

And even using external storages for mapping rooms to devices, I still can't pass them to the same function to generate a report:

It's quite possible that it can be done with a macro, though. I will try that.

1 Like

Just declare your hashmaps with &dyn Device:

let mut socket_by_room = HashMap::<&Room, &dyn Device>::new();
let mut thermo_by_room = HashMap::<&Room, &dyn Device>::new();

1 Like

This does work. This approach will allow me to pass the devices to a report generating function, but if I want to use my devices as actual concrete devices, I will have to store them in separate type-specific hashmaps. But this has the drawback of having to remember to add things to both the device-specific hashmap and to the device-agnostic one for the reports.

I think that your requirements are conflicting. You essentially want to both store different concrete types in a collection and use them dynamically/uniformly, and have type safety over the known static types. I don't think this is achievable in any language in any reasonable way.

5 Likes

You can still have an enum with the last variant being Custom(Box<dyn Device>).
May be it worth to explore other possibilities as well. As far as I can see, the library is modelling some kind of "world", which consists of many entities, which are quite different, but can have some common parts. Perhaps ECS(entity component system) would suit you needs better.

1 Like