State & future of dynamic polymorphism and OOP

As someone fairly new to Rust but experienced in C++, I find Rust's dynamic polymorphism and OOP features rather cumbersome to use. Specifically, I'm talking about:

Runtime downcasting

This can currently be done using Any, however, you need to modify the trait to allow downcasting from it, and the downcasting syntax is rather verbose:

trait Trait {
    fn as_any(&self) -> &'static dyn Any;
}

fn as_type<T: Trait>(object: &'static dyn Trait) -> Option<&'static T> {
    object.as_any().downcast_ref::<T>()
}

I don't see an implementation problem with this, as C++'s dynamic_cast does essentially the same without any trickery like Any and making traits dynamic_cast-able explicitly .

Boxing by multiple dyn traits

The following does not work, except for auto traits like Send and Sync:

trait FooTrait {
    fn as_any(&self) -> &'static dyn Any;
}

trait BarTrait {
    fn as_any(&self) -> &'static dyn Any;
}

// error[E0225]: only auto traits can be used as additional traits
fn multiple_traits(object: Box<dyn FooTrait + BarTrait>) {}

I know this can be worked around by creating an aggregate trait that encompasses both FooTrait and BarTrait, but unless you have a meaningful connection between the two traits (i.e. Rectangle + Rhomboid really makes a Square), this is just littering the code.

I'm not certain about the compiler implementation for this, as this is a bit different than multiple inheritance, but at first glance the semantics seem really straightforward.

Object-safe trait restrictions

You cannot currently have a trait like this in dyn contexts as it's not object safe:

trait JSON 
    where Self: Sized
{
    fn to_json_str(&self) -> String;
    fn parse_json_str(s: &str) -> Option<Self>;
    fn to_json_obj<Object: JSON>(&self) -> Option<Object> {
        let s = self.to_json_str();
        Object::parse_json_str(s.as_str())
    }
}

I think this could be worked around by splitting it into 3 traits and implementing ToJSONObj for dyn ToJSONStr, but that's again a lot of hassle and code littering.

This also doesn't seem to have implementation hurdles. In C++, it's perfectly legitimate to have template methods and static methods alongside virtual methods in an interface class, because C++ handles object-safety at the method level, not at the class level.

What is Rust's philosophy and future?

Due to the above, I find writing dynamic polymorphism and OOP code in Rust clumsy and unproductive. I wonder, why is this the case? Is it because Rust is a fairly new language and the OOP aspect hasn't gotten too much love yet, or is it somehow against Rust's philosophy to write such code in the first place? But if it's against the philosophy, then why does Rust seem to have the basic building blocks, just not the polish? Am I perhaps following the wrong approach to writing OOP code in Rust?

1 Like

It isn't so much that OOP hasn't gotten much love but that OOP is in generally frowned upon. Traits are used for composition, not for inheritance. Hence why the need to downcast is not as crucial as it seems.

A far more approachable way of doing polymorphism in Rust is by using enums.

2 Likes

Yes.

There were lots and lots and lots of attempts to bring OOP is a safe, sound way. They failed. I don't expect them to ever succeed, but who knows… stranger things have happened in the past.

Givne the fact that typical advice in the C++ world is You may use it freely in unittests, but avoid it when possible in other code. In particular, think twice before using RTTI in new code I would say that Rust just followed the general trend of where C++ was moving anyway: less use of dynamic plymorphism, more use of static polymorphism.

Why do you say this? -fno-rtti exists (and is often used) for a reason!

The general advice is to not use OOP with Rust, sure. Just like the modern C++ goes away from “classic” OOP… Rust does the same.

Rust includes things that can be implemented efficiently and doesn't include things that can not be implemented efficiently, more-or-less.

Some thing are proposed to be added and there would some clarification… but “classic OOP”… C++ ditched it before Rust, just look on what was added to C++17, C++20, C++23…

3 Likes

The need for as_any will go away as soon as trait_upcasting is stable, which should be soon (assuming fixing the last bug goes well) — you will still downcast through Any function's but you will not need to add special support to the trait for it.

Straightforward, and certainly wanted for a long time, but the implementation is the problem: if you have a dyn A + B + C + D, then to make sure it is usable, the compiler has to preemptively generate vtables for every upcast downstream code might want to perform: dyn A, dyn B, dyn C, dyn D, dyn A + B, dyn A + C, dyn A + D, dyn B + C, dyn B + D, dyn C + D, dyn A + B + C, dyn A + B + D, dyn A + C + D, and, dyn B + C + D. So, you need a total of 2^n - 1 vtables. There have been proposals for alternate designs of dynamic dispatch to avoid this exponential blowup, but none have been acceptable yet.

6 Likes

Rust isn't an object-oriented language, and isn't trying to be one. Most of Rust's most overt influences come from the functional programming community, though the implementation reflects a number of other influences.

Trying to write OO code in a non-OO language is pretty much always going to be awkward. If you find objects to be a natural way to think about the programs you're writing, you might find that Rust isn't the language you want to use for those programs; Swift or Julia might be more to your liking.

The "building blocks" you've identified are tools for doing dynamic dispatch. While object-oriented languages extend the notion of dynamic dispatch, the idea is useful in its own right, and does not necessarily entail an entire object-oriented system.

The use of the term "trait object" for values of type dyn SomeTrait, and the related term "object-safe," is an unfortunate historical happenstance that suggests more of a connection to OO languages than is actually present. The term "object" has a variety of meanings in a number of languages, and Rust's use of the term in practice, for me, calls to mind how the term is used in C, not how it's used in Smalltalk.

In general, it's a good idea to keep track of the type through your program rather than recovering it through casts. Your program had the full type of a value available when it created that value, so if your program will need that type later, don't lose it.

Rust provides a wide array of tools for doing so through generics and through pattern matching over enums. It also provides conversions on dyn types for those situations where you need them. Use what makes sense in your program, but the awkwardness you're running into is in some ways a suggestion not to do things that way in the first place.

There are two reasons for this.

One has to do with the way Rust requires all variables, function arguments, and function return values to have a known size at compile time. This is a choice that makes it easier to compile Rust to (correct) machine code, at the expense of some possible language features, but it's an unsurprising choice and is consistent with Rust's original goals of offering alternatives to existing compiled-to-native languages.

That constraint means that the functions fn parse_json_str(s: &str) -> Option<Self> and fn to_json_obj<Object: JSON>(&self) -> Option<Object> are only valid when Self and Object (resp.) are sized.

Unsized values do exist in Rust (classically, slices are unsized, for example), but they can't be stored in variables, passed as arguments, or returned from functions directly. Code has to operate on references when dealing with unsized values: references themselves are always sized. A dyn type is unsized, because a single dyn type may contain two different values, coming from different underlying types, which are not the same size as one another.

I would resolve this by removing the to_json_obj method from this trait, probably without providing an alternative. If you have some value of type T, and T implements JSON, calling value.to_json_obj() to get a new value implementing JSON seems superfluous. The provided implementation, which roundtrips the value from Rust to JSON and back, can always exist as a freestanding function or as a helper that lives with the code that needs it, rather than on the JSON trait.

The second is that the only way for Rust to dispatch function calls on dyn types is via its self parameter. That's the fundamental design of Rust's approach to dynamic dispatch. Since parse_json_str has no self parameter, there's no way to dispatch to that method.

This isn't just a quirk of the implementation. Taken in isolation, what actual type's implementation of parse_json_str should this call?

let value = JSON::parse_json_str(&some_string);

There has to be a single unique type to assign to value, for the program to be valid, and while inference based on later use may narrow down the range of options, none of that passes through the dyn type machinery. dyn and dynamic dispatch doesn't add much to parsing unless you're dispatching over the parser itself.

I would resolve this by moving this method out of the trait.

More generally, I don't see a significant aesthetic or design problem in breaking this down into two traits: one for going from values to JSON (which can be dyn-safe), one for going from JSON to values (which can't, for reasons outlined above), and a separate round-trip function for verification and testing:

// This is entirely dyn-safe
trait ToJson {
  fn to_json_str(&self) -> String;
}

// This is not, as its functions implicitly require a Sized bound
trait FromJson {
  fn from_json_str(&str) -> Option<Self>;
}

fn roundtrip_json<T, V>(value: &T) -> Option<V>
where
  T: ToJson,
  V: FromJson
{
  let s = value.to_json_str();
  V::from_json_str(&s)
}

If it's important that to_json_obj/roundtrip_json be dynamically dispatchable, that's doable, but the return type has to be a single non-dyn type:

// This is entirely dyn-safe
trait ToJson {
  fn to_json_str(&self) -> String;
  fn to_json_obj<V: FromJson>(&self) -> Option<V> {
    let s = self.to_json_str();
    V::from_json_str(&s)
  }
}

// This is not, as its functions implicitly require a Sized bound
trait FromJson {
  fn from_json_str(&str) -> Self;
}
7 Likes

Note that, if we allowed traits to be dyn-compatible even if not all their methods were, we would no longer be able to guarantee dyn Trait: Trait.

1 Like

Rust isn't an OO language. It's more like a low-level ML-family language that copied some C++ syntax.

It helps to treat Rust more like a "C with Classes" rather than an object-oriented language.

Don't construct hierarchies. Model data in "flat" way using multiple traits. With trait bounds working with types isn't like constructing a tree of types. It's more like defining SQL queries selecting which types work together.

The trait Foo: Bar syntax isn't inheritance, it's only a short form of trait Foo where Self: Bar that means "if you implement Foo you must also implement Bar, but that's an extra requirement, not a relationship. It could also be trait Foo where Vec<Self>: IntoIterator<Item=Self> and other arbitrary requirements like that.

6 Likes

(We already don't.)

1 Like

Note that Rust already allows to have methods that couldn't be used with a dyn Trait if you tell it that it's only where Self: Sized.

This sidesteps the issue because dyn Trait is not trait… but feels really weird, anyway.

2 Likes

Thanks for your input, and generally I agree that OOP has certain weaknesses, however, looking at it just like a tool, it still has its uses for problems that it's just better suited to model.

What inspired my question is that I'm trying to implement an in-memory relational database, with upwards of 50 column types that implement the same trait. Some table operations need the cells only through the trait, but others need to work on the concrete type which is known by the method. Using a dynamic_cast would be much cleaner for this case than an enum with 50 variants and a forwarding implementation for the trait.

In case you're not aware, you can generate the forwarding very easily with the enum_dispatch crate.

1 Like

Thanks for your feedback!

There were lots and lots and lots of attempts to bring OOP is a safe, sound way. They failed.

Understandable, however, I'm hoping that even if there won't be extensive support, the creases will get ironed out over time to make the simple things simple.

Why do you say this? -fno-rtti exists (and is often used) for a reason!

Unlike the Any trait, dynamic_cast does not need you to add any modifications to your code to make it work, and RTTI is also on by default, so you have to explicitly disable dynamic_cast. That's why I'm considering dynamic_cast "free". (I'm aware of the implementation costs.)

but “classic OOP”… C++ ditched it before Rust

I agree with you that OOP is less prevalent these days, and functional programming is gaining space, and for good reasons. However, I don't agree with the advice not to use OOP in general, rather, I'd say that if you use OOP, try to avoid its problematic parts, like deep and complicated inheritence trees and dynamic polymorphism in the hot loops. This goes for all tools though, and mixing and matching them to suit your problem is usually the best approach.

That said, it makes sense that Rust is first and foremost focused on FP.

The need for as_any will go away as soon as trait_upcasting is stable

That sounds good!

Straightforward, and certainly wanted for a long time, but the implementation is the problem: if you have a dyn A + B + C + D, then to make sure it is usable, the compiler has to preemptively generate vtables for every upcast downstream code might want to perform

Thanks for the explanation, I suspected that this is the problem. Would it not be possible to just generate the combinations you actually use in the code? This is what you do at the moment by hand anyway. This would only work if the vtables can be generated on-demand for traits in dependencies while compiling the consumer.

This would break dynamic linking of separately compiled Rust code libraries, which, while narrowly applicable (due to lack of stable ABI), is still something that is currently supported.

1 Like

Rust isn't an object-oriented language, and isn't trying to be one

What confused me a bit is that Rust is often considered an alternative or even replacement for C++, and C++ is a heavily multi-paradigm language, so I expected to find something similar in Rust.

Referring to @kornel 's comment:

It helps to treat Rust more like a "C with Classes" rather than an object-oriented language.

So far my impression is more like "functional C with generics and concepts".

If you find objects to be a natural way to think about the programs you're writing

You hit the nail on the head. I just don't think FP is very natural for the strongly stateful problem I'm trying to solve. This is only a small part of the application, so switching to another language is not really worth it.

but the awkwardness you're running into is in some ways a suggestion not to do things that way in the first place

Precisely why I asked for some opinions in the community. I appreciate the responses I got from everyone.

It is in the sense that it can be used in the same domains, to solve the same kinds problems, but Rust doesn't solve them exactly the same way.

Mapping C to Rust is usually quite easy, but with C++ there's a large impedance mismatch. Rust doesn't have data inheritance, doesn't have parameter overloading, doesn't have move/copy ctors, operator overloading is more restrictive. Rust doesn't have direct equivalent of C++ templates, they are half-way between Rust's macros and generics. Multiple inheritance is questionable in OO, but in Rust multiple trait implementations are the norm.
Virtual dispatch is completely different – it's not per method, and it's not limited to classes. You can use dyn on an integer, or even a zero-sized type.

Some the things that Rust lacks, like specialization and full constexpr support, are simply missing/unfinished features, but the rest of it is intentional.

So unfortunately many design patterns from C++ are going to be alien in Rust and require rethinking of the approach.

7 Likes