Adding true OO capabilities to Rust

On this topic, I definitely agree with you that usual OOP is certainly not a good fit for rust but that delegation can play an equivalent role and be consistent with Rust design at the same time. However I fear the OP may not be really interested in exploring other possible designs than the classical java/c# OO system

1 Like

I have to say, after reading the thread, I'm still not 100% clear on what exactly is "missing" from Rust. That being said, a few features that I think are missing that would ease this gap are:

Delegation

If I have struct Foo(String), I might want to be able to use this as if it were a String in certain contexts. In particular, calling certain methods on String on a Foo would be nice, without having to write out all the boilerplate myself. For example, in made-up-syntax, something like the following would be nice:

struct Foo(String);

impl Foo {
  fn bar(&self) {
    // ...
  }

  delegate String::len;
  delegate String::is_empty;

  // or perhaps:
  delegate String::*;
}

That way, I can "extend" String (and maybe even get its trait implementations too), but in a way that fits with Rust's current system. However, I'm pretty confident that could be implemented as a proc macro (though maybe the delegate String::* bit wouldn't be possible), so it's unclear this needs language work.

Specialization

The ability to "override" trait behaviour in certain contexts also would give some of the OO-style powers back to developers, though it's not clear how relevant this would be. The ability to provide a more general implementation for Foo<Bar> than Foo<T> is useful, and there are some performance footguns that it can help alleviate, but as an end user (rather than as a library maintainer), it's not something that comes up super often.

Though there are certain contexts (in particular UI work) where the OO-style seems to be more useful, and specialization could potentially help there, but even then, I'm not sure how much benefit is gained from deriving from a base Widget class and calling super.render() vs implementing a Widget trait and calling some_helper::render(). The Rust way has more code duplication, but it's generally minimal, and usually limited to "plumbing code", whose correctness can almost entirely be guaranteed by compile-time checks.


The term "OO" is (IMO) massively overloaded. Rust does have some features that people might call OO (e.g. polymorphism, encapsulation, and technically even subtyping). It's not clear initially what problems would be made easier by this proposed system, and why the existing solutions wouldn't work. That makes it doubly hard to imagine what the proposed system even would look like in Rust.

Many of the systems you seem to describe already exist in the language, they are just not the default. This is because Rust has a core value that costs should be explicit. If you want dynamic dispatch, use dyn. If you want downcasting, use Any. If you want an async runtime, grab one from crates.io and start it in main().To me, the only "OO feature" that Rust is truly missing is inheritance, and many people here (myself included) think that's a good thing. I'm yet to encounter a problem where I feel like I wished I had inheritance, but on the other hand, when I go back to OO languages, I often find myself desperately missing traits.

I think without a motivating example, or a more concrete description of what you think is missing from Rust, it's hard to see where to go forward with this. Such an example would certainly be more convincing than assertions that " it is my professional opinion that full and clean OO facilities are a huge, huge benefit to the developer community". Many people here are very familiar with OO languages, and simply won't agree to this statement without evidence.

But I always appreciate hearing different points of view, so thanks for you post :grin: Language design is hard, and the more people thinking about it, the better.

4 Likes

It can't. The problem is that proc macros cannot know the signature of the original function/trait.

3 Likes

Proc macros are purely syntax and have no access to type information, so you have to supply the full signature and documentation inline. At that point it's practically trivial to also include the implementation line, and the macro isn't saving you much of anything.

3 Likes

Ah yeah, forgot about the parameters/return :man_facepalming: . Either way, personally I just write these out by hand and it feels alright. A built-in mechanism would be nice for sure, but not essential. I should proof-read more thoroughly :laughing:

These top 3 features traditionally ascribed to OOP remind me of this talk, which I very much recommend you to watch:

Basically, the takeaway is that these features that are traditionally ascribed to OOP either a) aren't specific to OOP or b) aren't actually recommended even by OOP proponents.

The prime example of a) is encapsulation, which is orthogonal to OOP. Multiple people pointed out throughout the thread that Rust in fact does provide encapsulation. Personally, I would argue that the encapsulation features of Rust are actually superior to those of Java as Rust provides greater flexibility in terms what code has access to what data.

Example of b) is inheritance, especially multiple inheritance.

OOP was considered multiple times, there was a number of proposals for adding some kind of OOP to Rust, but none of those ever gained wider popularity and traction. Hopefully the above should provide understanding of the reason. The lack of "true OOP" in Rust is by no means an oversight or lack of understanding, it was thought-out decision.

9 Likes

I suppose since there isn't a law about what OOP is, it is what most people understand it to be. I think very few would say that encapsulation is not a significant feature of OO. (If you want to argue that some people do, I remind you that some people think the Earth is flat.) Further, it is my, well educated, opinion that encapsulation is the most significant feature of OO. As I think I stated in the past, the main app I work on daily has nearly 10,000 classes. A system this large would be impossible to maintain without encapsulation.

Object Oriented paradigms do provide encapsulation, yes. But it's not exclusive to object orientation. All you need for encapsulation is privacy controls, which are completely divorced from object orientation.

You can even get a poor man's version of private fields via opaque types in C:

struct Opaque;
Opaque* new_Opaque();
void free_Opaque(Opaque*);
void do_something_with(Opaque*);

Thus while encapsulation may be a goal and benefit of object orientation, it is not a defining feature of object orientation, because it's an orthogonal feature.

In Rust, you write

struct BigBallOfState {
    pub the_outside_world_can_see_this: State,
    the_outside_world_cannot_see_this: State,
    // ...
}
6 Likes

Yeah, because encapsulation and OOP is often (in case of languages such as Java of C#) delivered as part of the same 'package', by making an object the unit of encapsulation. However, the statement that they are orthogonal still stands, there are languages which are OO but don't encapsulate and languages which encapsulate but aren't OO. Have you yet familiarized yourself with the way Rust does encapsulation?

Rather than using generic words like "encapsulation", you could provide example code in Rust that demonstrates a specific feature you want to add to Rust. That might lead to some productive discussion.

17 Likes

IMHO, Rust already has excellent encapsulation, polymorphism, and metaclasses (traits), and I would argue it's design is much cleaner and better thought-out than most other languages I've seen.

I think the only major feature of traditional OO that is missing from Rust is inheritance (and the thread above seems to have narrowed down on inheritance too). In other OO languages I've seen inheritance is used to implement two things simultaneously: (1) code reuse, and (2) interface extensions.

I think traits and super traits already solve (2) reasonably well.

So that leaves code reuse. I would actually agree that code reuse is lacking in Rust, but I don't think inheritance is a good solution for it. I've been burned by inheritance in large projects (e.g., Hadoop) where it becomes impossible to figure out which class or subclass is actually executing. However I do believe that just having composition without a code reuse mechanism is problematic and a common source of boilerplate and tedium.

My favorite proposal that I've seen before is some form of explicit delegation. There was an RFC for it a while back, but it died, unfortunately.

8 Likes

One thing that inheritance helps that I miss in rust, is to be able to build a great ui library such as flutter for example, things such as reusable components and share this components as library or build new components on top of it, is very tricky to do without inheritance support.

1 Like

Can you give a more concrete example? Flutter doesn't seem to do things you can't do with just composition and polymorphism.

1 Like

What I consider to be the most problematic thing about inheritance is the ability of overriding implementations. This puts a lot of pressure on unsafe code, because it cannot rely on their own functions being overridden. I'll take rustonomicon's Vec<T> implementation as an example:

We can see this is the implementation of push:

impl<T> Vec<T> {
    pub fn push(&mut self, elem: T) {
        if self.len == self.cap { self.grow(); }
        // This is safe because we know that `grow()` definitely 
        // incremented the capacity, so there is NO WAY this is out of 
        // bounds. 
        unsafe {
            ptr::write(self.ptr.as_ptr().add(self.len), elem);
        }

        // Can't fail, we'll OOM first.
        self.len += 1;
    }
}

Suppose we wrote:

struct MyVec<T>: Vec<T> {}

impl<T> MyVec<T> {
    fn grow(&mut self) {
        // oops, forgot to actually grow my capacity
        // let's hope nothing goes wrong
    }
}

let x = MyVec::new(); 
x.push(10) // buffer overflow

All unsafe code is written with this in mind, adding such a feature would break the soundness of the entire rust ecosystem.

EDIT: Not to say that there aren't ways of achieving a similar thing without breaking unsafe code of course. Instead of overriding, shadowing could be used, such that push() still calls Vec::grow instead of MyVec::grow.

11 Likes

I agree completely: inheritance and encapsulation are at odds in that regard.

1 Like

I'd argue that UI library design has nothing to do with inheritance.

The biggest boon of OOP-y languages (such as Dart in Flutter or Swift in SwiftUI) is not inheritance is-a relationships. Instead, the big three things that makes a retained-mode UI easier in OOP-y languages are 1) pervasive, implicit shared ownership[1] (), 2) pervasive, implicit shared mutability[2], and 3) just generally, there being a lot more historical design work on how to design a good OOP-y UI framework.

The other big thing is that Rust is just generally a really strict language, and UI is really soft and dynamic. With a UI framework you really would prefer to be able to just compile whatever so you can quickly see the impact of the change you just made[3].

Other smaller things contribute a lot to a coherent feeling retained-mode design (and a lot of these features were designed specifically with the UI use case in mind!), such as field-access-syntax setter/getter functions (e.g. to dispatch listeners) and

It is true that because of shared mutable ownership being unergonomic[4] and/or difficult[5] in Rust, Rust UI frameworks tend to go for immediate[6]-mode or The Elm Architecture, which actively recommends against reusable components in favor of helper functions for shared functionality (whereas components are roughly objects for repeated display units).

It doesn't have to be that way, though. Raph Levien has been doing wonderful research work experimenting and designing a UI architecture which is fit for Rust, the most recent and promising being Xilem.

The only "inheritance" thing really used by UI frameworks is the ability to contain some AnyView. This isn't inheritance; it's polymorphic type erasure. And Rust supports that perfectly well via Box<dyn View>.

The other inheritance thing abused in UI frameworks is actual inheritance, being able to extend ~any component to add new behavior where the existing component is expected. While this can work, this is generally considered an antipattern and you should prefer making a new component which wraps the extended component instead.

In fact, SwiftUI is built entirely on struct types, and as such inheritance is not an option. Instead, it provides ViewModifier for a lighter weight way of extending existing view structs.

Reading Raph's post again has got me really interested in trying to build a UI framework using the Xilem architecture, the technique I mentioned of adapting types to a Copy view, and aggressive abuse of custom compilation tooling to get a live editing experience. But I very much don't have the time to do this, and have other things to do which I am much better suited to be doing solo in my spare time. That said, if someone were to pay me :eyes:


  1. GC in Dart, ARC in Swift ↩︎

  2. trivially sound so long as everything stays on the UI thread; easily thread-safe by all assignments being (atomic and) of pointer-sized values ↩︎

  3. it's for this reason I personally dislike code-only UI workflows, and strongly prefer extendible visual UI editors like JavaFX's FXML or SwiftUI Previews; the tangible visual artifact is key to rapid iteration ↩︎

  4. usually requiring, when single threaded, roughly Rc<Cell<T>> for everything along with cell projection down to individual fields if you're lucky, and a runtime not equipped to dynamically accelerate such use (such as via +0 and +½ passing) ↩︎

  5. a couple frameworks I've seen bits of shown off do a really clever but terribly tricky thing where the types you see are all actually Copy because they're just normal references but this gets automatically transformed back into owning pointers at the framework callback boundary to keep things alive, basically giving you a full scratch copy of the retained UI and diffing it for the updates needed to the real tree ↩︎

  6. on every update, you procedurally walk and create the entire UI tree again, rather than using callbacks and cross-references for incremental updates in retained-mode ↩︎

15 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.