“Baseline bounds”: an extensible replacement for `?Sized`

This post describes a new language feature, “baseline bounds”, which would enable new flavors of less-bounded type variables like ?Sized, in a way that is comprehensible and extensible to future needs rather than adding more special cases to the language. I do not think it is a good language feature by itself, but it might be a solution to language design problems in several other proposed features.

I already posted it on RFC 3729’s discussion but afterward, I thought it should be put in a more findable location than one PR comment.

Background

In Rust, every type parameter has bounds (fn foo<T: Trait>(...) which specify what can be done with it, but it also has a set of default assumptions about what can be done with it, that are not described by bounds. A type parameter with no bounds written allows the value to:

If the special bound T: ?Sized is added, then it is no longer possible to use size_of(), only size_of_val(), allowing dynamically sized types.

Occasionally there are proposals to introduce new functionality into the language which would require being able to have types which don’t support one of these things — being not movable (replacements for Pin), not forgettable (linear types), or not having an even dynamically known size (extern types, thin DSTs[1]).

The catch is that, since changing what Sized means is a breaking change, this would require introducing more things like ?Sized — but these anti-bounds are confusing and break the simple model that T: Foo + Bar means you can do Foo things and Bar things, so if there was more than one of them it would be unclear exactly how they interact. People have said the language team is reluctant to add more ? bounds despite how useful they would be for some things.

I have a idea for, if such weakenings are included, they can be provided in a way which avoids creating confusion and is extensible for future needs. I am not proposing this be added by itself to Rust — I’m just sharing it in case it is useful to further language design work that needs a solution to Sized being too one-size-fits-all or ?Sized being still too strong. This is not a pre-RFC.

Definition

There would be a new type of bound on a type parameter, known as a “baseline bound”.

  • Let's say the syntax is T: @Trait. (Symbol subject to bikeshedding, of course, but we can think of it as “begin @ this point”; it could also perhaps be given a contextual keyword if there is a good short one to be found.)

  • A trait can only be used in a baseline bound if any of the following are true:

    • It is the Sized trait.

    • It is a new trait the standard library provides to replace ?Sized bounds, i.e. which allows everything that you can do with any type in current Rust — let's suppose this is called NotSized, as a deliberately bad placeholder name. (The actual name of any such new trait should should be chosen to suit whatever extensions of the language are being added along with baseline bounds to support them.)

    • It is a user-defined trait whose supertrait bounds include a baseline bound, such as

      trait Foo: @Sized {}
      

      This constraint ensures that it’s impossible to write a type variable that has truly zero bounds, so new weakenings are always possible, and a trait can’t be used for its own baseline unless it opts in to offering that. (Note in particular that traits today essentially have ?Sized.)

      Changing trait Foo: Sized {} to trait Foo: @Sized {} is a non-breaking change.

  • Every type parameter has exactly one baseline bound.

    • If none is explicitly written, it is given @Sized as the implicit baseline bound; future editions may change this to another trait.
    • If two are written, this is an error.
  • Every trait has a baseline bound on Self (supertrait bound). If none is explicitly written, it is given @NotSized (equivalent to today’s ?Sized).

  • Every associated type of a trait has a baseline bound. If none is explicitly written, it is given @Sized.

  • Baseline bounds do not grant any functionality beyond ordinary bounds. Therefore, T: @Foo + Bar is completely equivalent to T: @Bar + Foo, if both Foo and Bar are permitted in baseline position at all.

Examples

  • T with no bounds is equal to T: @Sized in current editions (2015-2024). In a future edition, it could be made to mean something different.
  • T: Sized is equal to T: Sized + @Sized, hence just T: @Sized, in current editions. Useless and harmless today, still harmless in the future.
  • T: @Sized is “the only bound is Sized, regardless of the current edition” — it is redundant in current editions but would be added as part of automatic migration to a future edition that changed the implicit choice of baseline bound.
  • T: @NotSized is equal to today’s T: ?Sized, in all editions.
  • T: ?Sized has the 2015-2024 meaning forevermore; users may choose to replace it with T: @NotSized.
  • T: @SomeOtherTrait is an error unless SomeOtherTrait has an @ bound as a supertrait.
  • T: ?SomeOtherTrait does nothing and issues a warning, as today.

Benefits

  • Every trait that can be used as a baseline tells you what you can do with that type parameter, as an explicit item provided by a library. The language no longer privileges Sized uniquely (except for compatibility). Future versions of Rust can introduce new weaker or stronger baseline traits without introducing new syntax or new trait bound semantics.

  • Compared to language design proposals where a syntactically ordinary trait bound is given a special meaning (i.e. T: NotSized or T: Move), the presence of a symbol tells you that something special is going on, and hopefully the documentation of the trait will tell you what exactly that means in the case of that trait.

  • We can, if we wish, choose to stop using ?Sized entirely, so that all explicitly-written bounds are positive — they tell you the name for what you can do, not the name for what you can’t.

  • All code that uses an @ bound is now edition-change-proof; it has picked a named baseline and has opted out of all implicit bounds. This simplifies language evolution questions to the separate choices,

    • “is there room to split off a new weaker trait to serve this need?”
    • “what should the implicit baseline bound for the next edition be?”

    and we no longer need to ask “should there be more ? bounds” or “should there be new bound semantics or syntax”; because the baseline is named, there’s lots of room to add more baseline options as necessary without saying “this one is the correct one; we definitely got it right this time”.

  • User-defined baseline traits can make code more concise; T: @Foo express “this is bounded by Foo, Foo’s supertraits, and nothing else”; thus, it simplifies use-cases where one must today write <T: ?Sized + Foo> in every generic parameter.

  • Users who wish to write libraries which are compatible with, but do not require, dynamically-sized types (or extern types or other weakenings in the future) could choose a style (perhaps enforced by a Clippy restriction lint) where all type variables have an explicit baseline bound, forcing them to remember to choose between @Sized or @NotSized for each type parameter.

Drawbacks

  • More syntax.
  • Two ways to write T: ?Sized.
  • It might be that the extensibility this enables would be significantly bad for the language’s comprehensibility, and Rust should instead continue to not do any of the things it enables.

Closing remarks

Baseline bounds can be seen as similar to language editions, in that they allow incremental change without introducing new incompatible versions, by giving a name to “what set of default assumptions you are using”, so the name can be chosen explicitly, have documentation, et cetera.

Again, I am not proposing that this be added to Rust by itself. If you’re thinking “this doesn’t pull its weight” — yes, I agree, it isn’t worth doing unless it is justified by unblocking other valuable features. This is not a pre-RFC, just writing down an idea for reference.


  1. like a CStr defining its length by actually looking for the zero byte ↩︎

4 Likes

The status quo would be represented correctly by instead defining @Sized here, wouldn’t it?

(Dupe of the previous comment)

It has to be @Sized (or introduced over a version so that every pre-existing associated type can be autofixed to explicitly be @Sized), since associated types have the implicit Sized bound today.

trait Foo {
    type Bar: AsRef<()> /* + Sized */;
}

// Only possible because `Bar: Sized`
fn ex<F: Foo>(bar: &F::Bar) -> &dyn AsRef<()> {
    bar
}

I wish, but Sized is also special-cased for opting method and associated types out of dyn usability to keep a trait dyn-compatible. That is, there are other special cases of Sized bounds in the language.

3 Likes

Ah, I made an incorrect assumption. I’ll fix that.

Understanding from your proposal that

T: @Trait1 + Trait2 + … + TraitN
T: Trait1 + @Trait2 + … + TraitN

T: Trait1 + Trait2 + … + @TraitN

all mean the same, so really you’re introducing just a second style of trait bound.

  • One “containing @ somewhere” and one “containing no @”. There’s T: Bounds… that adds an implicit T: @Sized, and T: Bou@nds… that don’t.

Now I read your proposal as effectively achieving the following:

  • No type parameter is without any trait bounds, as seen by considering both cases
    • The “containing no @” style of trait bounds contains an implicit Sized bound
    • The “containing @ somewhere” style of trait bounds contains @Trait somewhere, so there is a trait bound

You do however pose additional rules

  • A trait bound T: Bou@nds… (i.e. list of trait bounds with an @ somewhere) must use @ only once
  • the @ is only allowed on traits that have an explicit : Bou@nds supertrait

I believe these two additional rules are completely unnecessary.

The first one is only a syntactic restriction anyway, as putting it on any (eligible) trait in a multi-trait bound has the same effect.

As for the second rule … you note yourself:

  • Every trait has a baseline bound on Self (supertrait bound). If none is explicitly written, it is given @NotSized (equivalent to today’s ?Sized).

so every trait could be “good” to be used as @Trait because every trait could make its implicit @NotSized supertrait bound explicit.

One could still consider whether there’s any benefit for a trait to opt out of this mechanism, but that’s not necessarily the case and even if so…

…the @ syntax was supposed to be a useful shorthand to avoid ?Sized + …, so why give it the additional (orthogonal) effect of marking “traits that may be used with @-syntax”?

Supertraits of traits can’t change anyways; strengthening them can break trait implementations; weakening them can break trait users.


So unless I’m missing something, this whole idea can be simplified and refactored into a new trait bound symbol, let’s say :@ instead of :.

  • current T: Bound… (where Bound… could be anything from empty, to a list of trait bounds Trait1 + … + TraitN) is shorthand for T:@ Sized + Bound…
  • the new T:@ Bound… is limited so that Bound… must actually contain at least one trait bound.
  • current T: ?Sized + Bound… is a shorthand for T:@ NotSized + Bound…

Well… coming back this, more accurately perhaps it’s: “Supertraits of traits can’t change anyways”, unless you can’t write trait implementations (sealed traits) or you can’t make use of the supertrait as a trait user.

Sealed traits are still not problematic, because their supertrait bounds still can’t weaken. This means that T:@ SealedTrait is always fine, because it does bound T with some “baseline” like NotSized or Sized that will never go away.

The other direction might be more interesting though… perhaps that’s also what you’ve had in mind; traits that don’t want to count as defining a “baseline”? Yes I think I finally understand the idea you tried to convey here Edit: perhaps I don’t understand, after all; see below

I still don’t understand why that opt-in should be done using the exact same @-syntax though. That is because I think it’s very reasonable to be wanting traits in the future that don’t have an implicit NotSized supertrait bound (instead, something weaker), but also don’t want to opt into being a “baseline” themself.


Actually… I don’t think your current proposal formulation adds up fully. That is because traits that want to be supporting to get weakened supertrait bounds in the future need to actually be changed so that their supertrait bound [at least the one that should support weakening] is not implied if the trait is used in a bound.

In other words, if I currently have a trait Foo (equivalently trait Foo: ?Sized) i.e. a trait with “only” a NotSized supertrait bound, but intended to be potentially supporting even further weakening of that bound. Your proposal ensures that T: @Foo can’t be written (T:@ Foo in my syntax) – however one can still write T: Foo + @OtherBaseline. And if OtherBaseline is supposed to be some future-introduced supertrait of NotSized (e.g. the proposed case of “types whose size might never be known”), then T: Foo + @OtherBaseline would still constrain T to NotSized as a supertrait of Foo, and weakening Foo’s baseline to @OtherBaseline (I’m assuming OtherBaseline is a supertrait of NotSized, so this really is only weakening) is breaking.

You are correct; these rules are not necessary for the proposal to work. They are, however, meant for a purpose. You’ve pointed out a place where I didn’t write down my design rationale.

My assumption is that by default, people don’t think about Sized when they write type parameters or trait declarations. In particular, today, all traits are implicitly Self: ?Sized relative to type parameters, and people only add trait Foo: Sized if they are forced to. This is good — by default, they get the more flexible trait. But if the language changes to have more opt-out properties, there will be (at least in editions ≤2024) opt-out properties for trait and not just type parameters, and people will also tend not to think about those either. As you point out, changing supertraits is a breaking change — therefore it is important to get them right.

The purpose of the @-supertrait rule is to ensure that if a trait is used as a baseline bound, the author of the trait has thought at least a little bit about the properties it should have, and written them down explicitly, rather than relying on the edition-dependent default which might not suit the purpose of the trait well.

This doesn’t protect against “oops, I need to make a breaking change to make this trait baseline-compatible”, but it makes it more likely that the choice will be noticed before breakingness is relevant.

For a generic function, one can say, for example, “hey, this parameter can be ?Sized and you gain dyn compatibility”, and that's not a breaking change. For traits, I think you want to be a little more careful and explicit when you have a trait that is relied on in a fundamental role.


However, I have no strong reason for the rule that there must be at most one @; mainly, I think that it is cleaner to “pick the baseline assumptions you are building on” and highlight the trait that you’ve chosen to provide them. But certainly there can in principle be cases where this is equally true of two traits. I see the argument for not restricting it, and unlike the @-supertrait rule, there is no hazard it prevents.

It is a breaking change for trait methods, though.

I meant that for an ordinary generic function, free or inherent, one can remove Sized (add ?Sized).

1 Like

This proposal seems to be specifically concerned with "vertical" extensibility, such as the proposal to have DynSized as a supertrait of Sized.

However, it's not clear how this would interact with "horizontal" extensibility, such as if ?Move was added.

would your proposed NotSized imply that a type should be Move? That seems.. unideal.

2 Likes

would your proposed NotSized imply that a type should be Move ? That seems.. unideal.

@NotSized in particular (which, I reiterate, is a placeholder name) would mean the thing that ?Sized means today. Since, today, values of a ?Sized type can be moved (it’s just hard to actually perform that move), if there was a Move trait, it would necessarily be true that NotSized implies Move — and Sized does too. This is unavoidable to avoid breaking existing code that makes the assumption of movability.

In order to enable generic code to work with !Move types, we would need a new baseline trait to describe types that are both not-necessarily Sized and not-necessarily Move. Then, from that baseline, you can add Sized, Move, or both as you see fit. They are usable separately or together just like any other trait bounds.

More generally, core always has to provide a baseline trait which is “the least possible assumptions you can make about a type”, to enable users to add back further assumptions (bounds) one at a time. Any time we add a new way for types to be more restricted in usage (linear, immovable, truly unsized, …), we need a new “lower” baseline trait for new code to talk about working within those restrictions. Of course, it’s desirable to not have too many iterations of such lowering, but the point of baseline bound syntax is that such superseded traits only end up as extra library items, not extra language semantics. All older baselines should be expressible as blanket implemented traits (or trait-aliases) for some combination of the “latest” traits.


Perhaps it would help to write out the definitions we’d be working with, in a world where we have all the fun features; we might have core:: traits and trait aliases like:

#[lang = "ultimate_baseline"] // exempted from the "must have @ supertrait" rule
pub trait Baseline2030 {}

/// Types that can be moved to a new address.
/// Like `Send` and `Sync`, auto-implemented but also manually implementable.
pub unsafe trait Move {}

/// Types that can be dropped.
pub trait Affine {}

/// Types that have a size accessible at least by reading the value or metadata,
/// and possibly also statically.
pub trait ValueSize {/*...*/}

/// Types whose values all have the same size.
#[lang = "sized"] // this trait has the compiler magic the `Sized` trait currently does.
pub trait StaticSize: ValueSize {}

/// Identical in effect to today's implicit or explicit `T: Sized`.
pub trait Sized = @Baseline2030 + Affine + Move + StaticSize;
/// Identical in effect to today's explicit `?Sized`.
pub trait NotSized = @Baseline2030 + Affine + Move + ValueSize;

Every possible set of properties can be expressed as a bound @Baseline2030 + ..., but some of them also have other names. (I’m not proposing to actually name a trait Baseline2030 except perhaps in the library internals; we’d probably want to give them more meaningful names. Having to come up with such names is one of the disadvantages of this proposal.)

1 Like

This is a very good framework to use for thinking about new default/baseline bounds. However, it doesn't really change anything about compatibility, other than by making things more visible:

Strictly speaking this is true, but in a somewhat unfortunate way: adding a new weaker baseline option to the language is only compatible if all current traits and associated types have the newly optional trait as a supertrait. Just as an example, if we have

trait Move: @Baseline2027 {}
trait Baseline2027: @lang#Baseline {}

then in the next edition,

trait Pointee: @Baseline2030 {}
trait Baseline2030: @lang#Baseline {}
trait Baseline2027: @Baseline2030 + Pointee {}

we end up with the technically unnecessary implication of Move: Pointee that only exists for compatibility.

If you wish to avoid “the technically unnecessary implication of Move: Pointee”, then you should not add those baseline supertraits; they should be just

trait Move {...}
trait Pointee {...}

without any baseline bounds, which means that all uses of these traits are required to (implicitly or explicitly) have their own choices of baseline. This is how you get the future-proofing that baseline traits offer. Above I proposed that Sized and NotSized would be baseline traits in order to express the status quo in terms of baselines (though one could instead introduce two new baseline traits) but other new “fundamental properties of values” traits should not also be baseline traits. (This is why my previous code sample introduced new traits ValueSize and StaticSize; those are the non-baseline traits which should be used in new code, in that hypothetical std design.)


As to associated types, yes, I agree that this proposal doesn’t provide any way to make associated types of existing traits more general. I think there may be a separate solution to that, though. (This is an idea I've had for a while, not specifically related to baseline traits.) If we have trait aliases, and allow an alias to be impled, then we can, for example, weaken Deref to not require NotSized as follows:

// In some future `std::ops`...

pub trait Deref2 {
    type Target: @Pointee;
    fn deref(&self) -> &Self::Target;
}

trait Deref = Deref2<Target: NotSized>;

Of course, Deref2 is an ugly name and this might be better handled with edition-dependent name lookup, but that’s another can of worms. But supposing we do exactly the above, then future code gets to write relaxed bounds using T: Deref2, and yet we have compatibility in both directions (old impl with new bound, or new bound with old impl) in the cases where the NotSized bound is met.

This same thing could also be done by introducing Target: NotSized as a bound whenever the Deref trait is named, conditional on the edition of the code naming it, but that seems to me importantly less flexible. Implementable trait aliases would be a feature users could benefit from too.

Ah, so a trait without any explicit baseline bound doesn't have NotSized as a baseline bound, but instead the edition-dependent lang#Baseline. And the restriction that you can't use traits as baselines unless they specify an explicit baseline allows the relaxation of said baseline in the future, in theory. Except, outside of std which supports the edition immediately…

  • If a consumer writes a generic T: @FutureBaseline + YourTrait, then they can use anything that's in YourTrait's baseline even if it isn't in the explicit baseline, unless baseline bounds are handled differently than normal trait bounds and never implied unless @-bound (which hasn't been specified).
  • How are default method bodies handled? They can use Self: lang#Baseline functionality, and there's no way to specify added bounds on them without also bounding the trait or method itself. Does this rely on some form of default impl that allows separating the default method body (and its required bounds) from the trait definition?

This doesn't work, unfortunately​, because it can require implying recursively infinite bounds, i.e. when the associated type has a bound with an associated type that (transitively) is bound on the same trait. So even the aliasing solution doesn't quite work for compatibility, because current edition code can rely on T::Output$(::Output)*: edition2021#Baseline. (Although it does work for fixed-depth like Deref.)

This was very annoying when I went to implement a PoC MetaSized unbound to exclude extern type from being used in generics. (But the bigger issue I failed to hack around was my failure to find a way to add an implied superbound to traits.)

No, that’s wrong……wait, I'm being inconsistent. I did write in the first post

  • Every trait has a baseline bound on Self (supertrait bound). If none is explicitly written, it is given @NotSized (equivalent to today’s ?Sized).

but it wasn’t what I was thinking when I wrote my previous post — I was at that moment writing from a world where traits have no implicit baseline bound at all. That does hit the problem you note:

How are default method bodies handled? They can use Self: lang#Baseline functionality,

My first thought here is that that should be no longer true (in the edition which introduces baseline bounds), and default method bodies can only use functionality from the trait and supertrait bounds. For example, if a default method body wants Move functionality, the trait would have to be trait Foo: Move (but not necessarily trait Foo: @Move). I’m not sure whether that works out.

But I don't understand why you mention “there's no way to specify added bounds on them” — what situation would be helped by being able to do so? Yes, there is a lack of expressiveness in that the trait must be bounded sufficiently for the default body to compile, and we could imagine changing that so if the default code can't be used you're merely obligated to write an explicit impl — but you can’t do that today either. Is that not a separate feature?

Really all that’s needed is something like “non-implied supertrait bounds”, right? Because those can be relaxed.

I.e. we have an edition introducing Move, then given a trait

// edition 2021 or so
trait Foo {
  /* … may or may not be contain type
  signatures or method bodies, etc… that
  need Self to be movable */
}

for the next edition this would be migrated into

// automated pre-edition-bump migration/fix,
// identical meaning in edition 2021;
// but now it works on edition 2021 *and* the new edition
trait Foo
where
    #[not_implied] Self: Move
{
  /* … may or may not be contain type
  signatures or method bodies, etc… that
  need Self to be movable */
}

(better syntax than “where #[not_implied] Self: Move” is definitely possible)

This means that Foo can still only be implemented for T: Move types, but users of the Foo trait still can’t be writing T: ?Move + Foo, because that’ll just say the trait bound T: Move is not satisfied.

(I’m writing ?Move and skipping on using some baseline-notation here, because this “non-implied supertrait bounds” solution is completely independent anyway.)

Then later, the trait author of Foo can evaluate the situation and decide either way, without breakage:

  • either Foo’s items[1] never needed (and never will need) Self: Move anyway, in which case they can lift the restriction
    trait Foo
    - where
    -     #[not_implied] Self: Move
    {
      /* … */
    }
    
  • or Self: Move will always be needed, in which case it’s safe to promote to proper, implied supertrait
    + trait Foo: Move
    {
      /* … */
    }
    

It’s not just default method bodies by the way, also something like

trait Foo {
    fn f(x: Bar<Self>);
}

would be a problem, of struct Bar<T> keeps a T: Move restriction.

(Here, too, the author of Foo can make a decision to make Move a supertrait, or to determine that Bar will probably be upgraded to support T: ?Move in the near future, and wait for that[2], to then remove the #[not_implied] supertrait.)


I would similarly suggest that probably something like Sized itself should not have Move as an implied supertrait (or, well, a supertrait at all). This means that a current

trait Foo: Sized { … }

too gets to decide freely on whether or not to require Move, in the same way as the other Foo examples above did.


  1. perhaps only with a small refactor; or after waiting for other code to remove T: Move restrictions first ↩︎

  2. or upstream that change ↩︎