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
todyn 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:
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?