Abstract Factory - Trait with generic method

I completely agree.

Yes, I already did that where I was able to use dyn (trait as object) and some local configurations that aggregate multiple concrete types in a concrete implementation.

I'm aware of this technique and I use it for other kind of situations, because I target native and wasm in some projects, and that imposes some degree of complexity, that I don't see as helpful in this context.

But I do understand what you mean.

Thanks!

1 Like

Yes, sorry, sometimes "type erasure" is used synonymously with dyn Trait: Sectional introduction - Learning Rust

Rust's type-erasing dyn Trait offers ...

dyn Trait of course does a lot more than just removing the need for a type parameter from the source code. It erases all type information, semantically. This is why you are required to downcast to get the concrete type back. That's a "neat trick" but it comes at a cost that is often too hard to pay.

I've had my fair share of debugging deeply nested types. (Looking at you, axum.) My take here is that if you can read the docs, you can find your way through any typeck mess. It won't be fast, mind you, and it won't be easy. Then again, we don't get paid to do easy things, do we?

It would be awesome to have an extra tool that could analyze the error with great detail. But this is the realm of the trait resolver, which as I understand it, is kind of rough around the edges. (The next-gen trait solver has been underway since 2015.) I'm optimistic that errors could get better in the future. I am pessimistic on the outlook of dyn Trait, though. The rules are too constraining and I don't know if it's possible to lift many of them.

1 Like

To be honest, most cases where I use dyn are cases where I really wanted was just to use a generic type and hide that information from the client code.

As most are dependency injection and not polymorphism.

But there is another perspective, while defining my macros' parse tree, I started using dyn, to allow defining the concepts outside the core, but the added complexity wasn't worth it, so I decided to use enum types till I was forced to use dyn,

This leads me to the powerful warning, if all you have is an hammer, everything looks like a nail.

In both problems, I just followed the criteria of reducing the cognitive load.

But both make me wonder if I'm using the right abstractions, if I'm not making nails because I don't have a screwdriver.

I feel your pain, I also had a lot of issues with axum, and the errors only helped to search or ask for the solution, not to guide or allow me to understand what was going on.

Thanks!

1 Like

If you’re still interested in exploring more options for EventStream in particular, I think it would help if you were able to share the Codable trait.

@quinedot already mentioned erased_serde

the whole point of erased_serde is to be a seamless drop-in, it’s designed to work with ordinary Serialize and Deserialize types and implementations, it’s not unlikely that you would not have to change any of the Codable trait nor its implementors at all, but it’s hard to intuit without any idea what that trait actually looks like :slight_smile:

1 Like

I already explained this before, but the Codable trait is:

pub trait Codable: Serialize + for<'a> Deserialize<'a> + Clone + Debug + Encode + Decode + PartialEq + 'static {}

That approach would imply me to use the erased_serde and probably do something similar to Encode and Decode, and I'm not sure if the added complexity does in fact help.

I would have to replace one generic parameter with four functions that receive or return any or a common object that encapsulates that.

And then in the client code I would have to add a as_any or a downcast, to get them back, or hide that in an object.

There are many components that use the current interface either as clients and as implementations, and I'm not sure the added complexity is a good idea.

The generic versions are in fact, the proper why to go, I just wanted to cancel the generic parameter propagation up the tree, that does yield very complex types.

Thanks!

1 Like

I was going over the Rust Survey, in the context of unimplemented or nightly features they asked about Type Alias Impl Trait (TAIT)

This code popups up:

trait Trait {}

type Baz = impl Trait;

impl Trait for u8 {}

fn foo() -> Baz {
    let x: u8;
    // ...
    x
}

fn bar(x: Baz, y: Baz) {
    // ...
}

struct Foo {
    a: Baz,
    b: (Baz, Baz),
}

I tried

pub type EventStoreImpl = impl EventStore;

But it seems not luck

`EventStoreImpl` must be used in combination with a concrete type within the same module

I'm not sure if its a limitation of the current implementation or some limitation I missed in the documentation.

It isn't stable. See the tracking issue linked in the RFC: Tracking issue for RFC 2515, "Permit impl Trait in type aliases" ¡ Issue #63063 ¡ rust-lang/rust ¡ GitHub

1 Like

I know, but I have used unstable features for a while with great success, to be honest I don't remember having any major issues with any of them, while allowing me significant code complexity improvements.

I already did and subscribed to their notifications, but to be honest I didn't found it useful to understand the status of my particular use case support.

I'm not sure it is what you want. The example from the RFC has this sentence directly following:

In this example, the concrete type referred to by Baz is guaranteed to be the same wherever Baz occurs.

Which suggests it is the opposite of what you want. This gives you only a single concrete type, but you want to be able to use many concrete types with the same name (IIUC).

And in fact, this fails with an error that makes it clear this is intentional:

#![feature(type_alias_impl_trait)]

trait Trait {}

type Baz = impl Trait;

impl Trait for u8 {}
impl Trait for i32 {}

fn foo() -> Baz {
    42_u8
}

fn bar() -> Baz {
    42_i32
}
error: concrete type differs from previous defining opaque type use
  --> src/lib.rs:15:5
   |
15 |     42_i32
   |     ^^^^^^ expected `u8`, got `i32`
   |
note: previous use here
  --> src/lib.rs:11:5
   |
11 |     42_u8
   |     ^^^^^
2 Likes

Or in terms of what I wrote before, TAIT is like -> impl Trait (RPIT): given any generic inputs,[1] there is one concrete type behind the opaque type. The name itself is a clue here: type alias impl Trait. Just like type Foo<T> = Vec<T> has to resolve to the same type for every Foo<ConcreteType>, so does type Foo<T> = impl Trait.[2]

All impl Trait except for those in function parameters (APIT) work like that,[3] including TAIT and all other unstable variants that I'm aware of. As far as I know the only related thing that may be in the works are for<T>-like bounds.[4]


  1. your EventStoreImpl has none ↩︎

  2. You also have to supply a defining use. ↩︎

  3. one of a few reasons why some, myself included, feel that APIT was a mistake (at a minimum in terms of sharing the same syntax) ↩︎

  4. And I'm not sure to what extent they'll be surfaced much less on what timeline; I've just seen some related PRs go by. ↩︎

2 Likes

You are probably right, I'm afraid.

When I read that, my interpretation was different, because in my use case, in fact that still applies.

When the code is compile in fact, only one concrete type is in practice used, only one backend is used, so that sill applies.

But your interpretation is probably the correct one, although the error is on usage and not on declaration.

Thanks!

I don't know, but in the RFC, they present very good arguments that also apply to my use cases, so I wouldn't be surprised if sooner or latter they might consider it.

Even if we go down the path of discussing that it could resolve to different concrete implementations, that doesn't mean that it would be ambiguous in any way to the compiler.

As the problem only could arise if you try to use them interchangeably, which doesn't happen in my use cases, but even so, you could use strategies similar to how you handle traits.

And from a code perspective, this would be simple to define and to understand, while allowing to remove code complexity and cognitive load.

In practice, I suspect that all the underlying mechanisms required are already supported using generics and what's missing is just a way to hide that from the coders / client code.

I believe they are static functions so you can find a way to pack them up into a virtual table (automatically done by trait-object) so that you can pass only one thing.

If you need then tell me which 4 functions you're going to use and where they come from so I can give some suggestions.

The key is packing up the specified needed part of type information into virtual table, then passing the virtual table. The difficulty is you have to know which informations is that you need and how to pick them. Passing all type information is unpractical (which requires infinite vtable size), which I mean you can give up the idea of bringing generics into trait object.

2 Likes

I agree that you’re probably right about

especially if they seem workable to you.

Just one more detail I would be interested in: You mention existing code using various aspects of the “current interface” already

I would be interested what parts of the interface you’re referring to. I.e.

  • is there much code already using the Codable trait?
  • are you also referring to much existing code using the EvenStream trait?
  • does this “many components that use the current interface” also refer to the EventStore trait?

I’m asking only because a refactor to allow type-erased users would likely work well without changing anything about Codable or its implementors or users, similar to how erased-serde does for the serde-traits. But, as you notice yourself already, then the fact that the method

fn create_stream<T: Codable>(&self, name: String) -> Result<Box<dyn EventStream<T>>, EventStoreError>

only takes the information of what type T is in the form of a type argument means that at least the EventStore trait (and then all of its implementors) would require some form of refactoring.


The final direction to look would be “up”. I.e. instead of looking down through the details of the EventStream and Codable and eliminate the need for generic parameters, instead your use case of things like your State struct can be looked at. Even though the API of EventStream isn’t, the API of State itself might end up being completely compatible with object safety again.

This would in particular be possible if your intended use-case

pub struct State {
    Store: Box<dyn EventStore>, // <- of course this doesn’t work
}

doesn’t make any use of the EventStore at full generality. This is in particular the case if State itself has no API with a generic T: Codable parameter because it’s only the person that defines and implements the details of the State struct that decides which concrete T: Codable types end up being used.

mod state {
    use crate::EventStore;

    pub struct State {
        store: Box<dyn EventStoreInState>, // instead of `dyn EventStore`
        extra: ExtraFields,
    }

    struct ExtraFields(/* … */);

    // intended API:
    /*
    impl State {
        pub fn new(e: impl EventStore) -> Self {
            todo!();
        }
        pub fn foo(&self) {
            // want to do stuff, while using `store` as an actual `EventStore`
            todo!();
        }
        pub fn bar(&mut self) {
            // want to do stuff, while using `store` as an actual `EventStore`
            todo!();
        }
    }
    */

    // How to actually do it

    // private trait, as implementation detail of `State`:
    trait EventStoreInState {
        // mirror (relevant subset of) `State` API signatures,
        // pass view of extra fields of `State` if necessary

        // these need to be dyn-compatible (e.g. no generics)
        // but if they are, it’s really straightforward what follows next
        fn foo(&self, extra: &ExtraFields);
        fn bar(&mut self, extra: &mut ExtraFields);
    }

    // next, simply delegate to the trait, which gives the majority of the API already
    impl State {
        pub fn foo(&self) {
            self.store.foo(&self.extra);
        }
        pub fn bar(&mut self) {
            self.store.foo(&mut self.extra);
        }
    }

    // construction is easy, too:
    impl State {
        pub fn new(e: impl EventStore + 'static) -> Self {
            Self {
                store: Box::new(e), // this works because of below implementation
                extra: ExtraFields(/* … */),
            }
        }
    }

    // finally, add the actual code/logic for implementing `foo`, `bar`!
    // this also establishes how to create the `Box<dyn EventStoreInState>`
    // in the constructor
    impl<E: EventStore> EventStoreInState for E {
        fn foo(&self, extra: &ExtraFields) {
            // at this place, we *do* have access to `self`
            // as a *true* `EventStore`, and also to all extra
            // fields of `State` 
        }

        fn bar(&mut self, extra: &mut ExtraFields) {
            // at this place, we *do* have access to `self`
            // as a *true* `EventStore`, and also to all extra
            // fields of `State` 
        }
    }
}

if your intended API of State does include any other generics, that’s additional complication, I suppose, and whether or not it can be avoided depends on the exact details. (Of course, if those methods don’t directly interact with the EventStore, but only through other non-generic helper methods, that’s completely fine; they just don’t become part of that internal EventStoreInState helper interface at all.)

2 Likes

The above actually lays out a general approach of how to hide non-dyn-safe generic arguments in a struct that itself has a dyn-safe interface (or at least one can find a dyn-safe internal interface).

For the case of EventStoreInState above, when the interaction with the EventStore is done by the implementor of State directly anyway, a minimal dyn-safe internal interface could just be a list of concrete

fn create_stream_foo(&self, name: String) -> Result<Box<dyn EventStream<Foo>>, EventStoreError>;
fn create_stream_bar(&self, name: String) -> Result<Box<dyn EventStream<Bar>>, EventStoreError>;
…

methods for different Foo, Bar, …: Codable types. (That of course still assumes that this list of actually used Codable types is finite; in fact, it makes it more obvious that that’s the case.)


The other approach of the code example in my previous reply does however have the great benefit that struct State can choose not to interact with the EventStream directly but within the methods foo, bar, etc… it can freely invoke other existing API that’s generic over E: EventStream internally.

2 Likes

I would prefer to apply that technique to the Rc because as some examples led me believe, that is the problem I have when I try to do:

  pub fn new(event_store: Rc<dyn EventStore>) -> Self
    where Self: Sized {
    Self { stream: event_store.create_stream::<T>("test".to_string()).unwrap() }
  }

Because then the method is only available to concrete implementations, so I have to define

impl<E: EventStore> EventStore for Rc<E> {
    fn create_stream<T: Codable>(&self, name: String) -> Result<Box<dyn EventStream<T>>, EventStoreError> {
        (&**self).create_stream(name)
    }
}

And here lies the problem, as this only works if I didn't restricted the method to concrete implementations. In theory, as far as I'm able to understand, the problem I have here is how to access the vtable of the concrete type also.

Handling the vtable here would allow me to make the changes some constrained locally.

Thanks!

The problem isn't regarding the Codable trait itself, but all the decorators and implementations of stream, that would have to be changed into a version with more cognitive load, and those are in practice fine with generics, which is in most perspectives the optimal approach.

So it doesn't make sense to make them more complex, just for achieving a minor simplification at the top level, at least in this case.

I understand that in external clients code it might make sense, for a question of encapsulation.

But this is dependency injection so, the client code still has to know about it.

let x: A<ConcreteEventStore> = A::new(ConcreteEventStore::new())

Of course it can get confusing when you have 5-6-7 types, but sacrificing the State doesn't help much.

Yes, I have thought about this strategy, but a simpler alternative would be to make the EventStore generic and allow the state to receive all the different EventStores implementations

pub struct State {
   store_a: Box<dyn EventStore<A>>,
   store_b: Box<dyn EventStore<B>>,
}

I might be missing something, but I already do this with 4 event streams directly in a decorator, so I know this works.

pub struct State {
   stream_a: Box<dyn EventStream<A>>,
   stream_b: Box<dyn EventStream<B>>,
}

Actually by doing this, I wouldn't require the EventStore, I could use the EventStreams directly, as I could, for instance, create all the streams on the struct initialisation for instance.

Yes, this would expand the factory into its real usage, and is doable.

But since the start my point was to reduce cognitive load, not increase it, as I already had a generic end to end version working.

My point was to sacrifice that a bit to reduce cognitive load.

Thanks!

Thank you for explaining this technique. It's very useful in general.

1 Like