Suggestion for making Rust's trait system more usable for generics

A language empowering everyone to build reliable and efficient software.

It's a generic statement and not related to (I'd suppose) targeting.

Perhaps sounds like a generic marketing blurb.

But unlike such fluff it is strangely very specific. It is a concise and precise statement of the purpose of Rust.

I wonder now what you mean by "targeting". What was the target market for C++ when Bjarne dreamed it up? C++ is everywhere, from Arduino playthings to high speed trading at financial institutions. Most application areas of C++ today were not even on the radar at the time.

2 Likes

This is basically what I put before. You said that this wasn't what you wanted.

But I think we were just talking past each other. In any case, I wouldn't want this level of generics for a normal api. It makes it much harder to implement and harder to learn.

Yes, that's why I don't want this to be implicit.

Yes, and this is how things work right now. One thing I really like about traits is that they only model behaviors. This makes them really easy to compose, because it is relatively easy to reason about behaviors.

In this thread at least, it doesn't look like people are complaining about object oriented programming, as much as inheritance. There are some very specific assertions, such as inheritance makes code harder to reason about, and harder to maintain. Rust does support some forms of inheritance, but it is very limited, structured, and for a specific purpose (traits).

Yes, because you said this generic implementation would also be required to use HashMap<u32, f32> with foo, which is not what I just wrote. The type parameter is only required if you want to use any type that implements HashMap<u32, f32>::Trait with foo.

Aha, I think this is where our fundamental misunderstanding stems from. I am thinking the more traits, the better, because you can use more generic functions.
But you are seeing that traits are opportunities for semantic misbehavior and also they make API comprehension more difficult.

I am not exactly agreeing that generic APIs are harder to understand, as I usually assume that the interfaces are well-defined, and then everything is quite transparent. But obviously this is a false assumption, as trait implementations could fail given unit tests.

I am generally thinking a lot about how to make a language generic and still unambiguously defined and correct. Using type systems you can definitely narrow down possible values for any arguments to a function and could potentially exactly encode the valid domain of the function exactly, and that way you could also encode semantics into the type system, but the closest to a type system like this that I have found was probably haskell, and even there I do not know of any builtin way to limit fundamental types to specific intervals. Although it is an obvious abstraction, especially if you had one or the other math class.

If overused, yes, generics can make very unreadable code. They are also harder to implement correctly.

Being "unambiguously defined and correct" for who? The compiler, the person reading the code, the person writing the code?

Have you looked into dependently typed languages like Idris?

1 Like

unambiguously defined for the compiler (and the reader consequently), correct for the author.

Not yet but it sounds very interesting to me. Thanks!

This is demonstrably not true. Things which are obvious to a computer can be incredibly hard for humans to understand.

4 Likes

I am also kind of used to the C++ OOP way, but looks like many modern language sort of gave up the way simultaneously, like Rust and Julia. I believe there is a reason for that, which I am not clear yet.

I think it basically boils down to C++-style inheritance-based OOP doing simultaneously too much and too little.

  • It does too much in the sense that it simultaneously tries to answer concerns of encapsulation, dynamic polymorphism, and implementation reuse with a single abstraction, which becomes complex and hard to use as a result (a bit of it is arguably accidental complexity, though, see Ada as an example of an inheritance-based design that separates those three concerns better)
  • It does too little in the sense that it has a hard time answering some concerns that became apparent when trying to apply this style of OOP at a large scale, such as performance loss in hot loops (due to forced allocations, pointer-chasing data model and loss of inlining) and difficulties scaling up and composing across large code bases (due to inheritance being only practical for tree-like hierarchies, with any attempt to generalize it to DAGs with multiple inheritance ending rather badly).
7 Likes

See here:
Jon Kalb “Back to Basics: Object-Oriented Programming”

And all will become clear.

1 Like

::inheritance

5 Likes

Yes, like I said in another response, this is similar to what I attempted last year (except ::inheritance goes the extra mile of actually featuring the proc macro for the delegation part). The need to mark your trait as "delegable" on definition is a blocker though. This is the part that would require language support IMO.

Thanks for releasing this crate, I'm pleased to see work in this direction.

Rereading Baby Steps, I wonder how much of this virtual struct concept can now be implementable using proc-macros.

While it's not very possible to really bridging the enum and struct concepts together without a touch of the language it self, i think using a composition of enums to show the taxonomy or hierarchy of concepts is do-able. Now we have very powerful tools of proc-macro, this process might not involve a lot of manual work. Many basic things, for example, providing get() /get_mut() pairs to the shared fields of enum variants can be achieved not too hard, i guess?

I feel that dealing with a single value is not the hardest part of the game, it's dealing with an object graph that's very hard, since the graph will quite possibly be circular, and states are represented as edges within the graph. Some tools like GitHub - nikomatsakis/mutable: Tinkering with a more ergonomic cell abstraction presented some interesting steps towards the solution, but it's still not there, i guess.

I have been using rust-delegate and for me it seems enough to automate delegation, which is probably the only thing I miss when I don't have inheritance.

Some of the features of OOP can be gained from the Deref and Borrow traits, the code reuse and type flexibility at least. I think Deref especially has a lot of power auto-ref.

struct SpecialMap { ... }

impl Deref for SpecialMap {
    fn deref(&self) -> HashMap ...

As for the crate authors creating traits for each struct I could see this being helpful but, it's easier for the user of the crate to create traits specifically for bounding generics.


I wonder if the self/Self keyword and impl's look too OO and this confuses newcomers because rust looks almost OO you want the same comfy familiar tools but they're just not there. It helped me to remember the self stuff is just sugar.

let mut v = Vec::new();
v.push(10);
// is just 
push(&mut v, 10);

mankinskin,
I truly appreciate your enthusiasm for wishing Rust adoption to a wider audience, I too would like to see more adopters. But I fear, as other have pointed out, getting more 'OOP' is not the way to go. And I cringe every time someone says I like 'OOP' because of X. Where in this case X is inheritance, which in general is not OOP. I would implore you too read a few articles by Alan Kay, or if have some free hours read The Art of the Metaobject Protocol

6 Likes

For future readers, these threads may also be helpful: Inheritance, why is bad? and How to implement inheritance-like feature for Rust?

From my point of view (coming from Java) who likes Rust enough to write a book Rust for Java Developers, Rust already has the sort of inheritance I would use in Java.

For data types you can do:

struct A { 
//...
}

struct B {
   a: A,
//...
}

and get all the relevant fields. This is sometimes combined with Deref, to unwrap an inner object.
But that's not common even in Java. Usually in Java, I would have one interface extend another, which you can do with Traits:

trait FooBar : Foo {
//...
}

Traits can also provide a default method just as interfaces do in Java. I would usually use this to implement one method in terms of the others in the interface, that way all the impls get it for free.
(Copied from Rust by example)

trait Animal {
    // Instance method signatures; these will return a string.
    fn name(&self) -> &'static str;
    fn noise(&self) -> &'static str;

    // Traits can provide default method definitions.
    fn talk(&self) {
        println!("{} says {}", self.name(), self.noise());
    }
}

This can be made even more powerful using Associated types.

However the real power comes when define traits in terms of other traits:

trait Foo {
   // Some methods I expect others to implement...
}

trait FooExt {
   // Methods I am going to implement...
}

impl<T> FooExt for T where T:Foo {
    // Implementation...
}

This way anyone can implement the Foo trait for whatever structure they want, and they will automatically get the implementation of FooExt on their object. This is a very powerful feature that replaces many of the usecases where you might use an abstract class in Java, but it goes further than that because it doesn't force a "child" to have only one "parent". Additionally it allows for much more powerful overloading, where Java Overloads functions based on the function signature, Rust does so based on the Trait, so you don't have to worry about collisions.

The book I'm putting together has some additional details, but it is very far form finished. If anyone is interested in helping out, let me know.

3 Likes

You probably want to write trait FooExt: Foo, if your trait is a strict extension to Foo, which the name suggests, i.e. your auto-impl is done for all possible FooExt.

@Phlopsi Their code is correct. The purpose of the FooExt trait is to add new default methods to an existing trait (like as if they had been defined in Foo).

In other words, nobody should ever impl a FooExt trait, only a Foo trait. That's why the blanket impl exists, so that way the methods will be automatically available on all Foo.

Some examples of the FooExt trait pattern: FutureExt, TryFutureExt, StreamExt, TryStreamExt, SignalExt, and SignalVecExt.

From FutureExt

pub trait FutureExt: Future {
...

Which is what @Phlopsi is proposing, this way no one can implement FooExt, they must get the derived version from the blanket impl

2 Likes