Idea: pre-declared generic type parameters

Generic type parameters often require you to repeat yourself a lot. Just to pick one example from the standard library:

impl<T> Option<T> {
    pub const fn unwrap_or(self, default: T) -> T
    where
        T: ~const Destruct,
    { ... }

    pub const fn unwrap_or_else<F>(self, f: F) -> T
    where
        F: ~const FnOnce() -> T,
        F: ~const Destruct,
    { ... }

    pub const fn map<U, F>(self, f: F) -> Option<U>
    where
        F: ~const FnOnce(T) -> U,
        F: ~const Destruct,
    { ... }
}

impl<T> Option<T> already looks redundant, but then we have all these where clauses as well, saying almost but not quite the same thing in each case. Wouldn't it be nice if we could factor that out somehow? Maybe like this?

type <
    T,
    U,
    ProduceT:    ~const FnOnce() -> T,
    ConvertTU:   ~const FnOnce(T) -> U,
    CanDestruct: ~const Destruct,
>;

impl Option<T> {
    pub const fn unwrap_or(self, default: T + CanDestruct)       -> T {...}
    pub const fn unwrap_or_else(self, f: ProduceT + CanDestruct) -> T {...}
    pub const fn map(self, f: ConvertTU + CanDestruct)           -> U {...}
}

This is intended to be a purely syntactic convenience. type <T>; impl Option<T> { ... } means exactly the same thing as impl<T> Option<T>. type <T, U, Convert: FnOnce(T) -> U>; fn map(f: Convert) { ... } means exactly the same thing as fn<T, U, F> map(f: F) where F: FnOnce(T) -> U { ... }.

The type < ... > notation is the first thing I thought of that avoided introducing a new keyword. If a new keyword is OK,

generic T;
generic ProduceT = ~const FnOnce() -> T;

might be more readable.

Like? Hate? Muffin?

1 Like

That looks very similar to a trait alias?

trait Produce<T> = ~const FnOnce() -> T;
3 Likes

The main issue is that this makes the generic list implicit. With APIT, we've said that implicit generics are acceptable, but that any implicit generics don't show up in the generics list for the purpose of turbofish application.

2 Likes

I don't know what APIT is?

This proposal was not meant to make anything more implicit than it already is. I think I didn't do a very good job of explaining it, let me try again with a different running example. This is the skeleton of the definition of Option, with irrelevant details erased:

pub enum Option<T> {
    None,
    Some(T),
}
impl<T> Option<T> {
    // ...
}

A programmer unaccustomed to either Rust or C++ might see this and think "Why must I write <T> three times?" The answer is that the type parameter T has to be repeatedly re-declared. pub enum Option<T> declares T as a type parameter, but it's only in scope for the rest of the item, so when we need it again for the impl block, we have to re-declare it. It is not obvious to me from the language reference why one may write enum Option<T> but not impl Option<T>, but that's not actually important. The point is, shouldn't there be a way to declare a type-parameter metavariable once and make it available for use in the rest of the file? That's the heart of the proposal.

type <T>;  // T is now known file-wide as a type parameter 
pub enum Option {
    None,
    Some(T),
}
impl Option {
    // ...
}

Again, this is not meant to imply that the type parameter for Option is now "implicit" in some sense. Its declaration has merely been factored out of its uses. The semantics of this code should be identical to the semantics of the original. I see this as much the same as how one need not write pub fn is_some_and<T> in

impl<T> Option<T> {
    pub fn is_some_and(self, f: impl FnOnce(T) -> bool) -> bool {
        ...
    }
}

The metavariable is already in scope, therefore it doesn't need to be re-declared.

I think what I've previously seen that postulated as is generic modules.

So you could have mod option<T> { enum Option { Some(T), None } } etc.

Which of course gets into the same "can you say Option<T> for that?" questions as those coming up in Allow importing assoicated methods - #2 by steffahn

2 Likes

Sorry, Argument Position Impl Trait. It's a standard enough initialism that I don't think to list it on irlo, though I usually will on urlo.

The problem is the question of "can you apply the generics to the name."

With

fn example<T>(f: impl FnOnce(T));

you turbofish it as example::<u32>(drop); you can apply an explicit binding for the T in the generics list, but the APIT cannot be specified, only inferred.

For

type <T>;
struct Example<U>(T, U);

the same should probably apply? Where Example::<u32>(0, Default::default()) works and specifies U but leaves T unspecified and inferred.

Even if we didn't have an intermediate period where capturing ambient generics prevented the use of explicit generics instantiation and/or where mixing captured generics with explicit listed generics wasn't allowed, I'm 90% confident that existing generics lists wouldn't be able to be replaced with ambient generics, because ambient generics won't be allowed to be specified in generics lists. And for that reason, I don't see ambient generic capture as likely to happen; APIT is one thing and typically intended to be used when the type will already be known at the call site (and potentially unnamable), but ambient generics make too many generics unturbofishable.

What I could see happening is a severely restricted form of it, e.g. something like

for<T: ~const Destruct>;

impl Option<T> {
    // ...
}

being allowed, where you define a name usable in impl headers only which isn't resolved to a concrete type, but instead makes the impl block generic over the type as declared.

This also sidesteps what I see the biggest problem with generic modules to be: what about items which don't "capture" the ambient generic? Are they still associated to the generic type and monomorphized for each one? (For fn the answer can both be semantically yes but trivially optimized to no, but it's plainly observable for static.)

I feel like you are imagining a much more ambitious feature than what I have in mind.

When I say "the semantics should be identical", I mean that

type <T>;
struct Example<U>(T, U)

should behave exactly the same as

struct Example<T, U>(T, U);

with regard to all possible uses of Example. I only want to reduce the number of times people have to write <T> in declarations. (And it would be nice if I could also reduce the number of times people have to write an identical where clause.) No changes to points of use are intended.

If this leaves you wondering why bother, consider:

  • The fewer times people have to write down a name, the less likely they are to typo it.
  • And the more likely they are to make it a descriptive name.
  • It is quite difficult to explain to a newcomer to the language why the impl block for struct Example<T, U>(T, U) has to be opened with impl<T, U> Example<T, U>. In fact, the only way I know how to explain it is in language specification jargon.

(Also, I do not think this hypothetical feature should replace the ability to specify an explicit generic type parameter list.)

2 Likes

I understand the why, it's just that APIT presents prior art that "implied" generics not present in the explicit generics list aren't specified when turbofishing the explicit generics list. If "desugaring" them into the generics list, you also have to answer what the order is, both to with the explicitly introduced generics and with each other. Saying "the order the ambient generics were introduced in" isn't all that satisfying, either, since Rust code is so far completely item definition order independent. You could resolve this by saying all captured ambient generics must come from a single ambient generic list which defines the relative order that they're in, but at this point we're piling on so many restrictions for just a little bit of sugar.

Actually, I'm expecting that the feature would be initially restricted like APIT was, such that use of the feature entirely prevents use sites to apply an explicit generics list to an item capturing ambient generics, to entirely sidestep the question initially (like was done for APIT).

I'm presenting the analogy to APIT not to say that such a feature SHOULD behave that way, but that it's not immediately obvious that it would just be sugar for prepending (or postpending) the list of captured ambient generics to the explicit generics list and thus be trivially turbofishable.

Without (much) jargon, the way I answer to this is roughly along the lines of:

If you just write impl Option<T>, there's no way of knowing whether you're intending to refer to a generic type T or to a concrete type T. The compiler could guess that if T isn't a concrete type in scope it should be a generic type, but Rust as a language doesn't like to make guesses like that; it could be that T is a typo of some other type, or that you forgot to import some type T. The inference could limit the risk by only treating single-letter types are generic names (e.g. meaning Instant would be correctly caught as a missing import), but Rust doesn't like to give semantic meaning to conventional naming schemes; this is why the nonstandard_style lint is just a warning rather than an error.

Aside: The compiler actually had used to have a feature almost exactly like this, but restricted to lifetimes, called in-band lifetimes, where using an undeclared lifetime would automatically declare it in on the containing fn signature. It was removed for essentially this exact reason -- it was unclear whether a lifetime was intended to come from the containing scope or be a fresh in-band lifetime.

You could alternatively say that if the name exactly matches the generic introduced on the struct definition, then it should be treated as a generic declared the same way. This is certainly a very reasonable choice, and the unstable implied_bounds feature essentially gives half of that (the bounds but not the generics). The reason this isn't done is perhaps simply historical; the choice of generic placeholder names has historically been an exclusively local choice for documentation without any impact on downstream code. In a way, it's analogous to how function argument names are private implementation details with no impact on downstream code, only shown for documentation purposes.


Personally, I'd be completely fine with "ambient generics" used for impl headers specifically, or even implied bounds being extended to implied generics for the generic names used on the type declaration. It's solely on other generic introducer lists where I feel there are too many nonobvious behaviors involved. "Explicit" is of course an overloaded term, but personally I think breaking the fn foo<A, B>/foo::<_, _> equivalent arity is more problematic than the reduction in boilerplate is beneficial.

Note also that ~const is an experimental/internal-only syntax currently, and has not gone through any RFC process[1]. ~const essentially doesn't exist yet. An ambient generics feature is still useful for normal trait bounds, but the noise overhead is much lower without ~const.

For anyone who isn't stdlib using pre-RFC experimental features, I'd suggest that they write the OP example as

impl<T> Option<T> {
    pub fn unwrap_or(self, default: T) -> T
    { ... }

    pub fn unwrap_or_else(self, f: impl FnOnce() -> T) -> T
    { ... }

    pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Option<U>
    { ... }
}

Much less noise. The lack of ability to turbofish the impl FnOnce is almost immaterial, as in most cases the type is unnamable anyway.

For cases like writing tokio, tracing, and/or tower where you can end up with giant where clauses repeating essentially the same bounds everywhere, the solution imho is trait aliases first.


  1. Personally, I'm still sad that ~const couldn't be the default for const fn; the ability to have bounds on const fn accidentally slipped in and wasn't completely gated, which in part led to the reasonable choice to allow normal nonconst trait bounds in const fn on stable, so stable const fn could use e.g. associated const. It's a reasonable choice, but one that unfortunately leads to most trait bounds in const fn being annotated ~const unless we switch the behavior in an edition. This falls to the domain of the keyword generics initiative to figure out. ↩ī¸Ž

2 Likes

The things you propose is quite similar to trait aliases and constraints. I don't know why they are in a limbo. I couldn't find an RFC for constraints, surprisingly. Looks like that's just something people regularly mention in the comments on trait aliases.

It looks like you want something that works like a macro, but is called like_this<..>; instead of #[like_this] or like_this! { .. } (but baked into the language, but that's not a big distinction, since there are things #[like this] and like_this! { } baked into the lang)

So... what about putting #![generic<T>] on top of the file? meaning it applies a <T> into every impl in the whole module.

I think in this case you would write option::<T>::Option. That is specify it when referencing the module that has the generic parameter.

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