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

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

@RustyYato Ah, right, it should have both the blanket impl and the : Foo

Inheritance was a mistake.

You may claim you don't have issues with inheritance. That's because you aren't actually using inheritance. Most uses of inheritance are really composition in disguise, because using inheritance is much easier in Java than using composition.

What do I mean by that? Consider the following Java program.

class Something {
    void methodA() {
        ...
    }
    void methodB(int param) {
        ...
    }
    void methodC() {
        ...
    }
    void methodD(String something, double value) {
        ...
    }
}

class SomethingEnhanced extends Something {
    void methodE() {
        ...
    }
}

Rather simple. Now, let's use composition.

class Something {
    void methodA() {
        ...
    }
    void methodB(int param) {
        ...
    }
    void methodC() {
        ...
    }
    void methodD(String something, double value) {
        ...
    }
}

class SomethingEnhanced {
    private final Something something = new Something();

    void methodA() {
        something.methodA();
    }
    void methodB(int param) {
        something.methodB(param);
    }
    void methodC() {
        something.methodC();
    }
    void methodD(String something, double value) {
        something.methodD(something, value);
    }
    void methodE() {
        ...
    }
}

It's not surprising that inheritance seems better when Java doesn't make it easy to use composition. Unfortunately, Rust doesn't make it easy to use composition either, but a better solution would be introduce some kind of syntactic sugar for composition than to introduce inheritance.

However, consider what would happen in inheritance example if SomethingEnhanced would decide to override methodC. The answer is, I don't know. Other methods may rely on methodC to do their job. They may not. Inheritance causes the program to depend on implementation details. Most programmers aren't used to implementation details being a part of a public API (it's kinda surprising, wouldn't you say?).

For a practical instance of this issue, you can have a list that counts how many times an element was added.

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;

class CountingList<E> extends ArrayList<E> {
    int counter;

    @Override
    public boolean add(E element) {
        counter++;
        return super.add(element);
    }
}

class CountingSet<E> extends HashSet<E> {
    int counter;

    @Override
    public boolean add(E element) {
        counter++;
        return super.add(element);
    }
}

class Main {
    public static void main(String[] args) {
        CountingList<Integer> list = new CountingList<>();
        list.addAll(Arrays.asList(1, 2, 3));
        System.out.println(list.counter);

        CountingSet<Integer> set = new CountingSet<>();
        set.addAll(Arrays.asList(1, 2, 3));
        System.out.println(set.counter);
    }
}

This returns... 0 3. You can check it on pastebin.run, if you like. What gives? Why extending a HashSet gives a different result compared to extending ArrayList? In short, a class not designed for extension was extended, and things ended up weird.

Sure, you could argue that it's possible to deal with this issue by also overriding addAll. Sure, but what would you do if a future Java version were to introduce a new addAllFromStream method or something.

With composition, only methods you know about are in the resulting class. There is no risk of Java suddenly adding addAllFromStream, because the method won't be available in your enhanced class.

That said, sometimes inheritance is useful. For instance, if you have a List which has plenty of methods, you can have an AbstractList class that an user can extend from and is designed with inheritance in mind.

Where is Rust in this? Consider an Iterator trait.

pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;

    fn size_hint(&self) -> (usize, Option<usize>) { ... }
    fn count(self) -> usize { ... }
    fn last(self) -> Option<Self::Item> { ... }
    fn nth(&mut self, n: usize) -> Option<Self::Item> { ... }
    ...
}

Okay, I think I will finish here, the trait is ridiculously long. There are 64 methods. Imagine implementing them manually.

... thankfully, you don't have to, as only next needs to be implemented, the other methods have a default implementation that calls your next implementation.

And of course, traits support inheritance. For instance, you can have an iterator that can yield elements from both ends.

pub trait DoubleEndedIterator: Iterator {
    fn next_back(&mut self) -> Option<Self::Item>;
    ...
}

In short, Rust can represent inheritance where inheritance is useful. The patterns where it's not, you probably shouldn't be using in languages that provide inheritance either.

6 Likes

I wouldn't go that far. However, it definitely is the number one victim of abused language constructs in OO languages. I also think, that Rust is not necessarily the right language to include such a high level of abstraction.

The one kind of inheritance Rust offers are sum types in the form of tagged enums. That's what people need in 99% of all cases.

2 Likes

Sum types I would hardly consider to be inheritance.

Rather, it is the other way around, using inheritance and type casts to simulate sum types. It kinda works, inheritance is so powerful that it can be used to simulate features that don't otherwise exist in a language, but it's not really that great at it - albeit worth noting languages introduced hacks like sealed classes to deal with the issues of this like being able to add additional sum type variants.

3 Likes

Sure, the term usage is debatable. I'm focusing more on the practical application and tagged enums in Rust cover a wide area of what people would use inheritance in other languages for. It's clear, that the enum is the superior language construct for groups of types, that don't allow for external extension, although one has to grow accustomed of enum variants not being types.

2 Likes

gazes mournfully at RFC 2593

Someday!

7 Likes

I don't understand why this sort of solution does not satisfy me like a sound design would do. Maybe I just don't understand all of the ramifications of the various design options, but my main concern is the verbosity of composition. Of course, explicitness is always a good first guess, you can't go terribly wrong with being explicit, but I feel like it hinders abstraction here. When traits are required to be explicitly implemented, there are no good options to build an abstract library on top of libraries which are not abstract/general (i.e. use traits). You are dependent on imported libraries to expose the right traits for you to implement, so that you can exploit their library as optimally as possible, with little boilerplate. If they don't offer traits and generic functions, you are forced to take the long way around and wrap their type in one of your types and use something like the Into<TheirType> trait, so that you have to call .into() to use their functions or methods.

I know it is a small disadvantage and there are still the advantages of being explicit about it, but I think this pretty much describes my dissatisfaction and what I meant in this discussion. I feel like there is potential to better exploit genericism, and to be able to do more with less code. Not allowing for genericity should be a conscious decision, just like allowing for genericity should be, and they should be equally expensive to implement. Currently I feel like implementing a generic API has more programming cost than implementing an (unnecessarily) specific API.

I don't think this should be discussed strictly on the grounds of "inheritance" as I don't think this is what this is actually about. Inheritance is one design to achieve abstraction, traits are another, better one. That does not mean that we should stop there. What I am diffusely imagining here is a kind of meta language, a type system for types. Types define a range of values, a set, if you will, of concrete bit sequences. They map a string of bytes with a specific size to a meta-structure which is essentially just an interface for humans. So types allow us to talk about data in a meaningful way, by being able to structure and emphasize according to our human intent.
Now we got traits and generic types, which map types to meta-structures which allow us humans to talk about types. Generic types describe ranges of types, just like types describe ranges of values, and traits are to generic types what properties are to structs. So a new trait actually defines a new type of types, and can be combined with other traits to form a hierarchy of traits.

I yet have to think of concrete ways this could be improved in Rust, as Rusts design is already ahead of its time. But maybe it is now a little bit more clear where I was going with this. Mainly what I am missing is syntactic sugar for these kinds of meta-types. For example something to build a trait definition from a type, so that you can just have a trait which is for "everything that is like this: <Type>". This would also make it easier to generalize an API once you have written a specialized version of it. Of course you could just copy the method signatures into the new trait yourself and implement it yourself, but lets be honest here, the work is to write the code, and any way to simplify that should be an improvement. There are more important issues, but maybe we can find some interesting ideas down this road.

4 Likes

I really dislike the idea of enum variants being types – they just aren't. They are a different concept – they are about the act of type-level composition (creating a disjoint union) itself, and not subtyping.

The resulting construct (the enum itself) is a type, but why the individual variants should be escapes me. Their arguments (associated data) already are types. It's akin to expecting that the + operator in the expression 1 + 2 also behave as a number.

I know that the linked RFC has enumerated use cases when it's convenient to treat enum variants as types, but I still find it fundamentally wrong at the conceptual level.

1 Like

It may be worth checking out delegate

2 Likes

This is flat out untrue. The orphan rule means you can only implement traits for types you own, unless you defined the trait.

If I need a generic map interface, either for switching between BTreeMap and HashMap, or for providing a testable MockMap, I can write one. I'm at nobody's mercy.

Here it is (modulo borrowed types:

trait Map<K, V> {
    fn get(&self, key: &K) -> Option<&V>;
    fn put(&mut self, key: K, value: V);
    fn delete(&mut self, key: &K);
}

impl<K: Hash, V> Map<K, V> for HashMap<K, V> {
    fn get(&self, key: &K) -> Option<V> {
        self.get(key)
    }
    fn put(&mut self, key: K, value: V) {
        self.insert(key, value);
    }
    fn delete(&mut self, key: &K) {
        self.delete(key);
    }
}

(Excluding some of the details around Borrow<K>).

This resolves the semver issue. The upstream type can implement any new methods they want. The trait implements the actual interface desired instead of willy-nilly copying every single public method of the upstream type. (Does a hypothetical MockMap need to implement capacity(), for example?)

Small, cohesive traits designed to serve a need will beat out general, automatic traits every time.

I agree that having a lower friction way to implement traits could be great, but I don't think this is it. I'd rather see some good proc macro crates that let you define traits based on existing methods.

Something like the following half-baked idea:

#[provides(Map, methods=["get", "insert", "delete")]
struct HashMap<K, V> {
...
}
1 Like

But this would not make it possible for me to use my type directly with an imported function which strictly only accepts a hashmap, right? The external library would have to implement the function accepting the Map trait, if i understand you correctly.
(My issue is not that it is possible to strictly accept one specific type, but that this is much easier to implement than a function accepting a trait/typeclass. that is why I think there is a lot of code out there which could be generalized, but isn't, simply because it takes a few more keystrokes to implement. A great example are functions only accepting &str or String instead of ToString or Into<&str> or something.)

1 Like

Ah, I see what you're saying. Yes, that's true.

Maybe. We already have impl Trait for function arguments, which isn't much harder, so I'm not sure it's so much a question of ergonomics as community values. For rust's core competencies, correctness of behavior, and control (over performance, argument types, binary size, compile times), are important. Proving correct behavior becomes harder if anyone can call your function with an ad-hoc, unsafe, possibly maliciously defined argument.

Sometimes that's the right thing to do, for any of the reasons above. Where it isn't, it's pretty easy to submit a PR to migrate a crate from &str to AsRef<str> or AsRef<Path>, or what have you. I think I'd rather see this solved by cultural solutions (contributing to the community, and shaping best practices) than technological ones (changing the language).

5 Likes

Oh wow, I didn't know about impl Trait in argument positions! This is a word to be spread, I only encountered impl Trait for return types and I only read about it in (a former version of) the tokio documentation.

Well this should satisfy my expectations quite surely.

1 Like

https://doc.rust-lang.org/book/ch10-02-traits.html#trait-bound-syntax

4 Likes

Thanks! I will definitely use that in the future. There are definitely more syntax optimizations possible, but I suppose Rust developers are already on it.

1 Like

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