Field-dependent trait bounds, or "are auto traits the right model?"

Auto traits are traits that are automatically implemented for a type when all of its type parameters also implement that trait. Creating an auto trait currently requires an unstable feature, but the intention is to make them available on stable eventually.

The behavior of auto traits could be restated as: "If Foo is an auto trait and T is a type, then 'all of T's fields implement Foo' is a sufficient (but not necessary) condition for 'T implements Foo'."

But consider the Copy type. Copy is not an auto trait. But it has a similar behavior: "If T is a type, then 'all of T's fields implement Copy' is a necessary (but not sufficient) condition for 'T implements Copy'."

It seems that both auto traits and Copy are specific instances of a general pattern of "the set traits implemented by this type depends on the set of traits implemented by this type's fields." So why not generalize this pattern as a new type of trait bound?

If we use the placeholder syntax fields(T): Trait to mean "the fields of type T implement trait Trait, then auto traits become:

trait AutoTrait {}

impl<T> AutoTrait for T where fields(T): AutoTrait {}

Copy's bounds would be expressible as

trait Copy: Clone + (fields(Self): Copy) {}

The fields() syntax is incomplete and not an actual proposal (in particular, it's missing a way to "opt out" of an auto trait for particular types), but I do feel there is a way to generalize the "field-dependent trait bonds" concept.

While this is a nice idea, like you said, it's problematic to explicitly implement the trait.

Also, I'm not sure it's worth it. Auto traits are rare; the standard library contains only six of them (Send, Sync, UnwindSafe, RefUnwindSafe, Unpin and Freeze). Sure, they can be useful for other libraries, but my feeling is that the priority is low. The reverse (traits that cannot be implemented unless they're implemented for all fields) is even rarer: I'm not aware of any example besides Copy. So trying to generalize that seems like a waste.

Is that really the intention? I'm not sure, actually, I'd be interested to read more on that. I thought auto traits leak implementation details in subtle ways you have to pay attention to when designing an API in order to avoid accidental breaking changes.

Also, I thought I might have read somewhere about problems of adding any new auto traits besides the already existing ones even to the standard library: Adding such a new auto trait is - in some sense of the term - a breaking change to the language, because it limits the set of allowed semver-compatible changes.

To phrase this in a way I just described a similar problem in a Zulip thread (archive link) that you participated in, too:

Suppose a new auto trait Foo was introduced. There are already existing crates with, say, version 1.2.3 and 1.2.4 differing by a change of the types of the private fields of some public struct, in such a way that the struct would implement Foo in 1.2.3, but wouldn't in 1.2.4. This used to be okay, because Foo didn't exist; there were no breaking API changes. Introducing a new auto trait can this have the effect that the existing version history of such a crate would suddenly, after the fact, be declared in violation of semver-guarantees.

(Note that this argument does not apply if the auto trait is only not implemented by some new, previously nonexistent types.)

One example of a trait that would benefit from being able to have Copy-like semantics is bytemuck's Pod trait.

1 Like

Note that I believe it is sound to have the following

#[repr(transparent)]
pub struct PodWrapper(NonPodType);
unsafe impl Zeroable for PodWrapper{}
unsafe impl Pod for PodWrapper{}

If NonPodType upholds the Pod invariants, but just doesn't implement the trait (ie. because it's declared in a crate that doesn't depend on bytemuck).

2 Likes

Reading the remainder of your post after writing my reply to the statement in the beginning, I do suspect that any way to write bounds about the fields of a struct will leak implementation details in a way that's in conflict with semver-guarantees.


Regarding the example of the Pod trait, I'm not sure I understand what kind of benefit you imagine. The safety requirements of that trait are more than just "all the fields must be Pod". It also requires a repr(C) or repr(transparent) struct, and furthermore that the fields are such that there are no padding bytes anywhere between the fields.

2 Likes

I believe the advantage would be in being able to bound the Pod trait on fields(Self): Pod, rather than merely an unsafe trait invariant that needs to be upheld (though, as I stated, this may be an overly restrictive bound).

There still would be an unsafe invariant though.

Also there are even certain implementations that don't need to fulfill the "all fields implement Pod" invariant: Option<NonZeroU8> and similar types implementing Pod, while NonZeroU8 doesn't.

Edit: Right, this more-or-less matches your example with NonPodType and PodWrapper; I only quite understood what you meant there on another reading.

It's worth noting that most auto traits are concerned with details of the type's memory layout/storage; and adding a private field to a struct is already a breaking change in that it changes the value of core::mem::size_of<T>(), which matters if that value is later used in a const generic context such that the new value causes a post-monomorphization error.

Yes, I'm not disputing that. I'm merely acknowledging that, in theory, this particular unsafe invariant could be lifted to a static bound.

core::mem::size_of::<T>() is unspecified and unstable for most types (including, but not limited to, any repr(Rust) types, where the compiler itself can do w/e it wants and change the layout for any reason or for no reason).

1 Like

My point here is that some things, like changing memory layout, are technically always breaking in theory, but in practice we only consider them breaking in certain specific cases (like #[repr(C)]). And a sane field-dependent-trait-bounds API would generally be like this as well.

Adding a method to a trait is a breaking change that is usually allowed (unless there is too much breakage).

1 Like

I would consider trait bounds a more pervasive part of the type system then some constants that could be used in types. Also, there is also the question of whether it's always sound for (in particular, unsafe) auto traits to be user defined. Any crate that authors unsafe code may have code that violates the invariants of an unsafe trait, but each of the fields implement (or would) implement it. If that unsafe trait becomes an auto trait, then now you have a soundness bug, possibly one caused by two pieces of code that are entirely unrelated.

3 Likes

Yes, there should probably be rules restricting unsafe auto traits (maybe auto traits only get auto-implemented if in scope at the type definition site?). Note that all these problems would apply to any proposal to stabilize auto traits, including what I believe is the current plan.

1 Like

This seems similar to the unsafe impl Copy proposal that comes up from time to time. Maybe something like unsafe impl AutoTrait for fields(Type) {} would cover such cases?

Also relevant is that the original and currently-proposed syntax for unimplementing auto-traits, impl !AutoTrait for Type {}, has the downside that the negative-impl syntax has since been overloaded to also have implications for semver and coherence. A potential impl !AutoTrait for fields(Type) {} would sidestep this.

Yeah. The last lang conversation I recall about auto traits is that we might never stabilize them. Being opt-out, it's incredibly hard for one to exist outside the standard library in a way that feels good. And even in the library, we want to avoid them as much as possible.

I just want to add to this that:

  • Freeze is explicitly a compiler-internal optimization (it would be valid to treat every type as containing UnsafeCell) and not user facing; rather it's a compiler feature re-using the auto trait machinery.
  • UnwindSafe and RefUnwindSafe are, depending on who you ask, somewhere between an agressive lint, useless, or an outright mistake.
  • Unpin (currently?) has deep ties into the validity semantics of the Rust Abstract Machine[1].

So only two auto traits (Send and Sync) are both unambiguously good and purely library concepts. (They encapsulate abstract machine concepts—whether and when it's valid to use a value from multiple threads—but the traits themselves have no impact on the operation of the abstract machine.)

Pod falls into the same category of purely library concepts, but they both have requirements beyond that of a simple auto trait (that being the padding requirements). (Zeroable also can't be a normal auto trait, due to the requirements of inhabitability.)


  1. Miri, the rustc const engine and implementation of the Stacked Borrows proposal for the Rust Abstract Machine semantics currently removes the requirement for &mut to be unique to !Unpin types (or something similar; it's complicated and I'm not exactly sure on the specifics). (NO YOU MAY NOT USE THIS PROPERTY IN YOUR unsafe CODE.) This is due to the issue that self-referential types (mostly, async created Futures) maintain internal-pointing &mut, and then are called via a Pin<&mut> to the entire Future—an aliasing &mut. Additionally, the async executor/reactor necessarily are holding some sort of pointer/reference to the future to facilitate their work. The exact way to formalize how this works on the Abstract Machine is still under the subject of evolving discussion. ↩︎

The ability to encode "requirements beyond that of a simple auto trait" is the purpose of this proposal.

I think it still does apply! Consider this:

// hapless_library version 1.2.3
pub struct HaplessStruct<T> {
  field1: Rc<T>,
  _marker: PhantomPinned,
}

// hapless_library version 1.2.4
pub struct HaplessStruct<T> {
  field1: Rc<T>,
  field2: T,
  _marker: PhantomPinned,
}

After you add an auto trait Foo which is only not-implemented by a new type NotFoo, a dependent crate can construct the type HaplessStruct<NotFoo> and assume that it implements Foo, which will work in hapless_library 1.2.3 but not hapless_library 1.2.4.

1 Like