Idiomatic Rust-way and separation of abstractions. Design problem: cast trait object 'dyn Entity' to another trait

I'll illustrate a design problem and then ask a fundamental question about idiomatic Rust-way. Here's common C# code: library provides Service and works in terms of processables:

interface Processable
{
}

class Service
{
    void process(Processable processable) {}
}

Client code works in terms of entities. Entity is interface that defines a polymorphic behavior (important: dynamic dispatch is required here). Class MyEntity can implement interface Processable, and now all the mighty power of Service is available for MyEntity. It can be done in 2 ways.

Approach 1: without interface inheritance

interface Entity
{
}
 
class MyEntity : Entity, Processable
{
}

// ...

MyEntity entity = new MyEntity();
// works: casting to one of MyEntity's interfaces
processor.process(entity as Processable);

Approach 2: with interface inheritance

interface Entity : Processable // inheritance
{
}

class MyEntity : Entity
{
}

// ...

MyEntity entity = new MyEntity();
// works: entity IS Processable
service.process(entity);

I have hard times implementing any of this in Rust. Ok, traits are similar to interfaces, but not the same. Let's forget approach 2 with "interface inheritance" (Rust doesn't like inheritance at all, and supertraits have different semantics). Upcasting trait object dyn Entity to supertrait dyn Processable wouldn't work in Rust (edition 2021).

Approach 2 in Rust:

trait Processable { 
}

struct Service;

impl Service {
	fn process(&self, processable: &dyn Processable) { 
	}
}

trait Entity : Processable {}

struct MyEntity;
impl Entity for MyEntity { 
}
impl Processable for MyEntity { 
}

fn process(entity: &dyn Entity) {
	let service = Service;
	// compile-time error:
	service.process(entity as &dyn Processable);
}

fn main() {
	let entity = MyEntity;
	process(&entity);
}

Error:

cannot cast dyn Entity to dyn Processable, trait upcasting coercion is experimental
= note: see issue #65991 Tracking issue for dyn upcasting coercion · Issue #65991 · rust-lang/rust · GitHub for more information
= note: required when coercing &dyn Entity into &dyn Processable

Why? Answers:

  1. oop - Why doesn't Rust support trait object upcasting? - Stack Overflow

  2. Older draft 2 - Dyn upcast initiative

  3. GitHub - rust-lang/dyn-upcasting-coercion-initiative: Initiative to support upcasting dyn Trait values to supertraits

Let's try to implement approach 1 (without "interface inheritance") with Rust. We hit another wall.

Approach 1 in Rust:

trait Processable { 
}

struct Service;

impl Service {
	fn process(&self, processable: &dyn Processable) { 
	}
}

trait Entity {
}

struct MyEntity;
impl Entity for MyEntity { 
}
impl Processable for MyEntity { 
}

fn process(entity: &dyn Entity) {
	let service = Service;
	// compile-time error: non-primitive cast: &dyn Entity as &dyn Processable
	// an as expression can only be used to convert between
	// primitive types or to coerce to a specific trait object
	service.process(entity as &dyn Processable);
}

fn main() {
	let entity = MyEntity;
	process(&entity);
}

Working solution: provide a casting method Entity.as_processable()

	trait Entity {
		fn as_processable(&self) -> &dyn Processable;
	}

	struct MyEntity;
	impl Entity for MyEntity {
		fn as_processable(&self) -> &dyn Processable { self }
	}
	impl Processable for MyEntity { }

	fn process(entity: &dyn Entity) {
		let service = Service;
		// ok: it compiles
		service.process(entity.as_processable());
	}

	fn main() {
		let entity = MyEntity;
		process(&entity);
	}

The problem is: for every type that implements Entity we have to copy-paste boilerplate code:

fn as_processable(&self) -> &dyn Processable { self }

If Entity works with multiple services, multiple cast-methods would be required. It's inconvenient, but we can live with that... We can write macro for that, too.

But I have another question. See, it's fundamental idea to separate abstractions: library uses Processable and knows nothing about client code (Entity), so library can be reused, that's why client code has to mix own concepts (Entity) with library's concepts (Processable). It's not even OOP-thing in general. We often speak about "idiomatic Rust", and how "Rust is not OOP", and how "Rust enforces good code", and how "one should forget OOP habits and understand Rust way". But it looks like Rust doesn't provide convenient solution for described fundamental idea. So, is it just Rust not being perfect, after 18 years of existence still dooming us to write a boilercode in trivial cases (so mentioned rhetoric about "enforcing good code" is exaggerated)? Or Rust indicates that there are better design solutions and forces us to prefer them? So what are these solutions?

It seems to be a limitation of trait objects, and also seems relatively close to having a solution:

I don't know of workarounds other than the one you posted, but maybe others do.

1 Like

You can implement Processable for &dyn Entity as well, if that'd help in your case:

struct Service;

impl Service {
	fn process(&self, _processable: impl Processable) { }
}

trait Processable {}

trait Entity {}

struct MyEntity;

impl Entity for MyEntity {}

impl Processable for MyEntity {}

fn process(entity: &dyn Entity) {
    let service = Service;
    // ok: it compiles
    service.process(entity);
}

impl Processable for &dyn Entity {}

fn main() {
    let entity = MyEntity;
    process(&entity);
}

Playground.

4 Likes

There's a pattern for this. Your OP does add the wrinkle that the trait object you want to type-erase to is in a foreign crate, so a supertrait bound on Processable isn't possible.

// Make the ability a trait
trait AsDynProcessable {
    fn as_processable(&self) -> &dyn Processable;
}

// Provide it for every `Sized` implementor of `Processable`
// so they don't have to
impl<T: Processable /* + Sized */> AsDynProcessable for T {
    fn as_processable(&self) -> &dyn Processable {
        self
    }
}

// In lieu of a supertrait on `Processable` (which is foreign),
// also implement it for `dyn Processable` ourselves
impl AsDynProcessable for dyn Processable + '_ {
    fn as_processable(&self) -> &dyn Processable {
        self
    }
}

Then for your playground, you add Entity: AsDynProcessable and the compiler will supply the implementation for dyn Entity et al.

// Compiler supplies `impl AsDynProcessable for dyn Entity + '_`, etc
trait Entity: AsDynProcessable {}

Also: you probably want a Processable supertrait bound too... not sure why you didn't have one since this is "approach 1".


Some of these things don't come up, or at least come up in different form, when generics instead of type erasing everywhere is used. Not saying you're wrong and that nothing could be improved, just pointing out that you're probably hitting more or different rough edges by dint of still "thinking in C#" or such.

Rust does involve a lot of boilerplate. Sometimes (but not always) you can at least encapsulate that boilerplate (like the blanket implementation so not everyone has to repeat the implementation).

Sometimes it's because of some limitation which will hopefully go away with time (e.g. supertrait upcasting), and sometimes it's due to other design decisions (e.g. post-monomorphization errors are bad so trait implementations must be complete, and thus less of them can be provided automatically/unconditionally).

Incidentally, I said this in another thread of yours, but Rust has been stable 9 years, not 18. (2012 Rust is pretty unrecognizable, say.) But be it 9 or 18, I don't think putting "...after $N years of existence..." on every question/gripe is going to add anything positive to your posts. It's not going to change reality or motivate someone to make things more how you wish they were, or motivate those already doing so to do so faster.
6 Likes

Thanks for the elegant solution - and also for the link.

Do you mean trait Entity : Processable {} ? It's Approach 2.

Agree. :slight_smile: I was overreacting. And in both cases solution was found.
I like Rust and believe in its future.

Thanks, jumpnbrownweasel, jofas, quinedot for your answers. Have a good day!

3 Likes

Whoops, indeed.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.