Discussion: add grammar `trait FooExt for Foo`

Currently, due to many reasons, we may define extend trait for some structs. For example, CommandExt for Command It is obvious that, CommandExt should only be implemented on Command struct, and thus the following code seems a little bit redundant:

trait CommandExt {
    // method definations;
}
impl CommandExt for Command {
    // impls of method definations
}

Should we add some new grammar, allow write those things in a block?

/// by default, we may marked CommandExt as a sealed trait
/// it might be crazy to impl FooExt for structs other than Foo
trait CommandExt for Command { 
    // impls of method definations
}

Since the trait CommandExt should not be used elsewhere, such defination seems more clearly.

Are there any disadvantages?

2 Likes

This has been implemented as crates. No need for this to be built-in.

5 Likes

Firstly, both of the crate you mentioned (of coursely) using proc-macro, which might adding compile time by a little, and proc-macro are regarded unsafe since it could do anything.

Secondly, such crates lack of support by official, thus it may yield incompatitable code in the future version.

Lastly, there is a lot of things firstly been implemented as crates and later enter the std built-in, such as hashbrown and lazy_cell. "we have a crate thus we do not need this" is just a rude criterion.

4 Likes

The existence of a crate implementation is not sufficient to say that something shouldn't move into the toolchain; it just shows that experimentation has taken place outside the compiler, and gives you a way of assessing the idea against two criteria:

  1. Has the design space been fully explored?
  2. Is this useful enough to justify making everyone who works on the toolchain pay the cost of keeping it working?

The underlying reason for both of these criteria is that Rust has very strong backwards compatibility goals; if I have a crate that compiled and worked on Rust 1.x, then the expectation is that I can upgrade to any later version 1.y of the toolchain and have my crate compile and work.

This means that once something's in std, you should expect it to stay there forever (even core::mem::uninitialized has stayed around, although there is no sound way to use it); that's why criteria 2 exists, because there's a price to keeping anything around. And criteria 1 exists because you can't remove parts of a stable feature; if you've designed it badly because you didn't explore the design space properly before committing, you're stuck with the bad design forever.

So, if you're pointing to existing crates as a counterargument, you need to point out what about those crates suggests that locking this API in place forever is going to hurt the Rust project more than the project gains from having that API built-in.

10 Likes

I think the trait would need to be Sealed as well. Possibly even in a way that other types in the crate cannot impl it. What stops someone from writing:

impl CommandExt for MyStruct {
    // what goes here?
}

What methods are required? Defaulted? What parts should be Self or are Command regardless of what is doing impl CommandExt? These are hard questions whether MyStruct is in the same crate or external to it.

6 Likes

Thank you for your correction for my grammar mistake:)

Sealed might be a good idea, but I am unfamilar with it, thus I cannot say anything about that. Maybe I can add one more reason about such grammar:

trait CommandExt for Command  // this could set `CommandExt` as a Sealed trait automatically
1 Like

Dwelling on it a bit more, this just feels like an "impl for remote type" thing that happens to use trait to do what it is doing. Maybe there's a better way of declaring this? One new thing I see is that impl Foo is suddenly introducing a name rather than opening a block to enhance an already-declared name. Maybe a better syntax is trait CommandExt for Type where Type must be concrete (no generics or anything)?

The nice thing about being a trait is that there's something to use to bring the new methods into scope. Other than that, it really doesn't provide anything a trait normally does. where T: CommandExt is kind of pointless because only Command can implement it, so the genericity is excessive.

2 Likes

This is one of those things that is perfectly plausible to implement, and likely wouldn't even be too troublesome depending on the exact semantics.

I guess my question is mostly: are extension traits really used so much that it deserves its own dedicated syntax? It's difficult to tell since the answer to that question is subject to e.g. perception bias.

The relevance is of the question is mostly that it seems reasonable to me to say that a language feature that is used once in a while but not all that much overall doesn't deserve an extended novelty budget (along either the syntactic or semantic dimensions), especially since the status quo, while slightly inelegant, isn't all that burdensome to begin with.

I'd rather have the rather limited engineering resources available to the Rust project as a whole be used for things like librarification of especially rustc, stabilization of full const generics etc. Those things already seem to take forever¹ without other features vying for the same attention.

Now of course if it turns out that extension traits are used a lot everywhere, there's a case to be made for extending the feature. But that would need to be shown first, I think.

¹ I'm speaking purely perceptually. I'm acutely aware that it's a slog going through all that, so it's not meant as a criticism in any way.

3 Likes

Bikeshedding a little here, is this worth a new keyword (strict or weak)?

Something like

extension impl CommandExt for Command {
…
}

where the keyword extension is used to indicate that this is an extension implementation.

You can then use thing::CommandExt; to bring it into scope like an extension trait, possibly with the same semantics as use thing::CommandExt as _; to avoid unnecessary namespace pollution, but it's not a trait for the purposes of things like trait bounds, and you can't implement the same extension for multiple remote types.

having the name of the extension trait visible is actually quite important for the niche scenario where two extension traits define the same method, so that they can be disambiguated via the <T as SomeTrait>::method syntax.

3 Likes

I would argue that extension traits (that exist solely to be implemented by a single impl block) are underused specifically because of the extra weight that it takes to provide method syntax. Since function syntax is always sufficient, many potential users of extensions will just write functions instead outside of applications where method chaining and/or receiver autoref is highly impactful.

While traits are the mechanism, what extensions actually are expressing is a way to write methods which avoids the problems that coherence exists to prevent. If you include "why can't I write an impl for this type" as well as usage of extension style traits, I think the utility threshold is easily passed.

And also FWIW, phrased in my preferred shape of "free methods" (detailed below) instead of sugar for "extension traits" the feature fits fairly well into the "make existing things just work in more cases" box and is thus a removal of a restriction (methods must always be associated functions) as much as it is an extension to language sugar.

I don't really think so; some combination of trait and impl is reasonable. At a language level this feature is combining the trait definition with the impl block and so that's what the syntax should say. Like how newtypes are just struct and not some additional keyword to say "this is a newtype style structure."

My personal favorite concept, however, is to just allow directly writing free methods, e.g.

fn f(self: &Command, …) -> … {
    …
}

then when f is in scope, it's available to method lookup on the Command type. That it's a method is determined by the use of a self parameter.

What is perhaps a bit interesting to ask in this case is what happens when you use f as g. It could then be available as cmd.g(), or it could behave like the trait version does[1] and still provide cmd.f(). Whether it supports use f as _ to only be available to method lookup is also a question.

In the case where it's just a function instead of a trait, it's just f(&cmd) for disambiguated function call syntax instead of the more involved qualified UFCS.

And to that note, qualified UFCS is another potential option to address the underlying want here, e.g. allowing to write something like cmd.(path::to::f)() to call an item-path-lookup function with method syntax (and all that entails, e.g. autoref) instead of always needing to insert names into method lookup.


  1. I haven't written nor looked for a proc macro that provides this syntax, but it's possible to polyfill by generating a trait with the same name as the free fn. ↩︎

5 Likes

It’s not my own personal favorite for a number of reasons, but the main one is that the name I would use for a method, in the context of a type or instance, isn’t the name I would use for a free function (which is still relevant because of universal call syntax). I’d end up just putting the free functions in a module named for the type anyway, and now we’ve reinvented extension traits but with less indication to rustc and rustdoc and humans that that’s what they are.

4 Likes

The restrictions (sealed) part could be phrased in terms of RFC 3323.[1] Personally I think allowing the extension to be non-sealed is reasonable (think newtypes).

I definitely feel that is should have a direct desugaring to something you could write without the feature. I.e. you get an actual trait out: you have to import it to use it, it has a name you can import, you can name it in paths and qualified paths, you can bound on it, you don't have to bound on it if you know the concrete type, etc.

Then if you decide to update this extension in some way that hasn't grown an inline annotation yet, you can rewrite it like you had written an extension trait without this feature in the first place, and not break or have to change anything elsewhere.


Other loose thoughts...

  • The implementation should be like an implementation for the named type IMO, and not defaulted methods (at least by default)

    • Then you can name fields, use inherent methods, etc, in the method bodies
    • Then you can declare associated consts[2]
    • Then you can declare associated types and GATs[3][4]
  • Using Self is ambiguous sometimes... probably should mean "the implementor" outside of function bodies and other item definitions, and the target type within them

  • How generics are declared needs sussing out

    • "you don't declare them" is challenging w.r.t. const types and probably other things
    • "they are what's in the trait generics" is additionally inadequate when the target type has a non-lifetime generic[5] that doesn't correspond to one in the trait
    • something something defaulted trait parameters... especially const again
  • What happens with where clauses needs sussing out

    • Header where clauses should arguably go on the implementation and not the trait (unless they are on Self?)[6][7]
    • Item where clauses have to go the trait or the implementation wouldn't meet the requirements[8]
  • Most of these things could just not be part of the MVP


  1. though the OP seems to want something stronger than impl(crate) trait technically ↩︎

  2. which don't support default values, so far anyway ↩︎

  3. which don't support defaults either yet ↩︎

  4. even when there is exactly one implementation, this unlocks things like shared opaque return types and a way to name otherwise unnameable things elsewhere ↩︎

  5. which you want to be generic over ↩︎

  6. thinking about the lack of non-supertrait elaboration here ↩︎

  7. OTOH something something Trait<X: Iterator> where Self: From<X::Item> ↩︎

  8. modulo trivially satisfied clauses ↩︎

I think a fundamental question that needs to be answered is whether the use case wants specifically to define an extension trait, or if it only wants to define a bundle of additional methods for some receiver type(s) that are imported separately and traits are just a convenient way that already exists to talk about such a bundle of functionality.

The reason I think answering this is important is that in the second case, a lot of questions now have fairly obvious answers or don't even really apply anymore.

  • Only the primary impl block ever exists, so whether default function bodies are provided is irrelevant.
  • How generics and generic bounds are captured for the bundled trait doesn't particularly matter either, since neither imports nor UFCS need to specify the generic list on the trait.
  • Code isn't ever expected to be generic over or bound with the extension bundle, but instead to express the generic bound for which the extension is written.

A potential syntax that emphasizes the "impl first" understanding might be

impl<…> SelfTy<…> as trait TraitName
where …
{ … }

This isn't directly writable in Rust today, but this could be thought of as desugaring to something like

trait<…> TraitName
where Self: TraitName<Self = SelfTy<…>>, …
{ … }

When the trait isn't restricted to just the usage as an extension bundle, the extra syntax for separate trait and impl communicates meaningful information. I do agree it's desirable that an extension bundle be forwards compatible with evolving into a proper trait in the future, but the benefit of a more terse spelling for extension traits goes hand in hand with the more constrained application of extension traits as just an extension and not a trait for generalization of code.

Yes - Rust is already a large complicated hard to learn language, this would make the situation even worse.

1 Like

apon further reflection, i think there should be some friction in defining extension crates, since their overuse can lead to several problems.

  1. makes in unclear which crate defines a method
  2. adding a new function to an extension trait is actually a breaking change, since it may overlap with another extension trait on the same type

these issues are actually perfectly analogous to the issues caused by wildcard imports.

1 Like