Sealed traits

Feature Name: sealed_traits

Summary

Trait definitions can have a #[sealed] attribute placed on them. This stops users in other crates from implementing the trait.

Motivation

It is common to want a trait that can only be implemented for certain types. This is true in both the standard library and many crates. It is currently possible to create a trait that is public but unnameable. This prevents users in other crates from implementing the trait, but also from using it.

mod private {
    pub trait Sealed {} // Users in other crates cannot name this trait.
    impl Sealed for u8 {}
}

pub trait Actual: private::Sealed {}
impl Actual for u8 {}

This approach has drawbacks. The documentation generated by rustdoc is not very good (through no fault of its own), as private::Sealed is sometimes where the implementation actually takes place. If private::Sealed is used as a generic bound, Actual is not necessary, resulting in the trait being completely undocumented: only the trait name is visible in documentation. Because two traits are necessary in some cases, it is possible for the user to implement private::Sealed but not Actual. This is a logical bug, but one the compiler lets happen.

Having sealed traits as part of Rust itself solves these issues and opens new use cases. A trait that is sealed would appear alongside other items in documentation, but could contain an annotation (similar to the one for #[non_exhaustive]) telling the user that they cannot implement it themselves. Sealed traits eliminates the need to have “helper” traits to restrict implementation: the compiler enforces it instead. This can lead to improved error messages, reducing frustration.

Explanation

A new attribute, #[sealed], is introduced. This attribute can only be placed on trait definitions. A “sealed trait” is any trait that has the #[sealed] attribute placed on its definition.

Assume the following trait definition and implementation.

#[sealed]
pub trait Foo {}

struct Bar;
impl Foo for Bar {}

When Bar is in same crate as Foo, the #[sealed] attribute has no effect. If Bar is in a different crate, a compiler error will be emitted. For example,

error: cannot implement sealed trait
  --> $DIR/sealed-traits.rs:25:1
   |
LL | impl SealedExternalTrait for Local {}
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
  ::: $DIR/sealed-trait.rs:3:1
   |
LL | #[sealed]
   | --------- trait sealed here
   |
   = note: sealed traits cannot be implemented outside the crate they are defined in

It is a breaking change to add #[sealed] to a trait that already exists. This is because users in other crates may have implemented it. It is not a breaking change to remove #[sealed].

Implementation

A new compiler pass for coherence is added that checks if a trait with #[sealed] is being implemented outside of the crate it is defined in. The compiler will emit an error if this is the case. This new pass is similar to the existing pass for unsafe traits and #[non_exhaustive] enums.

Whether a trait is sealed has no effect on anything other than restricting implementations. This means that applying #[sealed] to a trait does not affect coherence.

Drawbacks

This introduces a built-in attribute for behavior that can be partially accomplished in library code.

Rationale and alternatives

The existing workaround is widely used and has its drawbacks (mentioned above). One alternative is to introduce native syntax. This would require a (possibly contextual) keyword to be added.

Prior art

  • Sealed traits are currently simulated by using a public trait in a private module.

    mod private {
        pub trait Sealed {}
        impl Sealed for u8 {}
    }
    
  • The sealed crate is a procedural macro that generates a public trait in a private module.

Unresolved questions

None so far.

Future possibilities

  • The #[sealed] attribute could accept a path, as in #[sealed(module)] or #[sealed(in module)], restricting the ability to implement the trait to only that module.
  • Trait items could be extended to have full support for visibility, just as structs do. This would eliminate the need for continued use of the workaround for when private methods are wanted.
  • As this proposal does not affect coherence, it is possible that a future RFC could have #[sealed] affect coherence in some way. This would likely require different or additional syntax. This is because removing #[sealed] is not a breaking change, but it would be if it affected coherence.
19 Likes

Please use this topic for feedback on the text itself, such as if something is not clear. I am aware of previous pre-RFCs and have taken them into account.

The "implementation" section is a bit light, but that's intentional. There really isn't much to it — a speculative implementation is already complete.

Have you given any thought to having the compiler use #[sealed] for "exhaustiveness checking"? For example:

#[sealed]
trait Sealed {}

#[derive(Debug)]
struct A {}

impl Sealed for A {}

#[derive(Debug)]
struct B {}

impl Sealed for B {}

fn <T: Sealed>foo(t: T) {
    // We know all implementers of `Sealed`, and they are all `Debug`,
    // so we know `T: Debug` must hold as well
    dbg!(t)
}
2 Likes

I don't think the compiler should allow this, but it could make useful suggestions in this case (such as having Sealed imply Debug). I don't think that needs to be in the initial version though.

8 Likes

One other alternative worth mentioning, which I don't think we should do: sealed could be a keyword instead of an attribute. (I prefer the attribute, but I think it's worth documenting the keyword alternative for completeness.)

I would love to see this proposed as an RFC, and implemented.

5 Likes

Yes, and I don't think it should be done. Removing #[sealed] should never be a breaking change.

I specifically added this remark to cover these possibilities

Whether a trait is sealed has no effect on anything other than restricting implementations.

One alternative is to introduce native syntax. This would require a (possibly contextual) keyword to be added.

:wink:

2 Likes

Prior discussion:

1 Like

If allowed, this should probably use the same syntax as pub(in path), i.e. #[sealed(crate)] (default), #[sealed(super)], #[sealed(self)], and #[sealed(in path::to::module)]. It isn't needed for disambiguation the way pub(in path) is, but is still useful for consistency. At the very least, in path should be accepted.

There should be an alternative here that #[sealed] does impact coherence, by promising that downstream types do not implement the trait.

For example, it would be nice to refactor Wrapping<T> to have blanket implementations over some #[sealed] trait PrimitiveInt, but this requires that the blanket is known to not overlap downstream implementations on Wrapping. Similarly, the fn page has a huge number of basic trait implementations for each function pointer type individually; making this generic over a #[sealed] trait FnPtr would significantly reduce the noise on that page.

The benefit of removing #[sealed] being a nonbreaking change shouldn't be overlooked, but neither should the benefits of having it impact coherence.

Having it impact coherence also makes it impossible to represent purely in user code.

FWIW this I would not expect to impact coherence.

10 Likes

For clarity, I'm not opposed to impacting coherence. But I think the RFC as written will be easier to get accepted, implemented (I've already done this), and stabilized. Impacting coherence would mean different syntax (read: bikeshedding) and uncertainty over the extent to which it should impact it.

My intent was to submit a PR with the RFC as written, which is why I was primarily asking for concerns about the text.

2 Likes

I completely missed that by looking in the wrong section. Thank you. :slight_smile:

For clarity, I'm not opposed to impacting coherence.

I think the RFC text should specifically mention that this version of sealed traits doesn't offer coherence changes, and that such changes would need a different syntax. That way, instead of readers having to infer “this doesn't do that and can't ever do that” from the implications of

It is not a breaking change to remove #[sealed] .

they can see it immediately.

2 Likes

There's no reason that #[sealed] cannot impact coherence.

I agree here, and do think that a non-coherence-impacting form can/should be stabilized first. However, it should at least be considered whether removing #[sealed] should be considered breaking such that coherence implications can be implied by #[sealed] in the future as the coherence engine improves.

Mainly, I just want some discussion of this in the alternatives and/or future possibilities section.

1 Like

I think part of the issue is that #[sealed] may or may not be permanent on a trait, but there's no way to tell the difference. Imo if something affects coherence, it should be clear that this is the case.

I'll add a mention in the future possibilities section about affecting coherence, indicating that I believe it should require additional syntax (without specifying form).

2 Likes

Oh, this is a very good point. Darn, that means we can't delay the choice.

The possibility of coherence being aware of this is a huge part of the value, to me. Since that's what solves the common "can I implement Debug for everything that implements my trait?" questions.

Hmm, what, specifically, can break if sealed is removed? What things can it break outside the crate? Presumably outside the crate it's hard for anything to rely on sealed, because the crate owning the trait can still add more implementations in future -- it's not fundamental -- and thus it might only be able to remove the coherence restrictions for things inside the same crate. Maybe that would make it "non-breaking" to remove sealed, but in the sense that "well, if crate A defines a sealed trait A, then if it removes it either crate A gets a compilation error, and thus it can't really remove it, or it doesn't get a compilation error and thus it can remove it, because it can't break anyone else"?

That might be a tolerable middle-ground, if it works out.

5 Likes

Crate A:

#[sealed]
pub trait Foo {}

#[derive(Debug)]
pub struct Alpha;
impl Foo for Alpha {}

Note that all types implementing Foo also implement Debug.

Crate B:

fn foo<T: Foo>(foo: T) {
    dbg!(foo); // acceptable if coherence is affected
}

Removing #[sealed] from the trait definition would break this.


it might only be able to remove the coherence restrictions for things inside the same crate

This could theoretically work and would be forward compatible with doing nothing now. However, this has the caveat (that you hinted at) that any use in a public API could add implicit bounds that are undesirable.

Note that all types implementing Foo also implement Debug.

I would not expect T: Foo to imply T: Debug, neither locally nor downstream.

When I think of relaxing coherence for sealed traits, I mean cases like this:

pub struct MyStruct;

pub trait Foo {}

pub trait Bar<T> {}
impl<T: Foo> Bar<T> for MyStruct {}
impl<T: Foo> Bar<&T> for MyStruct {}
error[E0119]: conflicting implementations of trait `Bar<&_>` for type `MyStruct`
 --> src/lib.rs:7:1
  |
6 | impl<T: Foo> Bar<T> for MyStruct {}
  | -------------------------------- first implementation here
7 | impl<T: Foo> Bar<&T> for MyStruct {}
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ conflicting implementation for `MyStruct`
  |
  = note: downstream crates may implement trait `Foo` for type `&_`

This fails even if Foo is private, which is a little weird, but it would be nice if #[sealed] removed that downstream possibility altogether.

14 Likes

Ah, I understand. This feels like something specialization would cover, no? Why would there need to be an additional mechanism to do this?

I don't know, maybe specialization could do that with the lattice rule, but that sounds like a hack if we actually know there's no possible overlap from downstream.

2 Likes

Consider the case of

#[sealed]
trait A {}

#[sealed]
trait B {}

trait C {}

impl<T: A> C for T {}
impl<T: B> C for T {}

These implementations have the same "priority" w.r.t. specialization, so can't be handled by specialization. It requires the compiler to know that the set of types A and B are disjoint.

(To make it more interesting, consider the case where C is in a parent crate and A and B are in two different sibling crates. I'm not sure if this should be allowed, though?)

To be clear, the only coherence refinements I'm interested in are those of reasoning about the set of types that can implement the sealed trait w.r.t. overlapping implementations.


Separately, I don't really see an argument for why a trait should be #[sealed] that can also be lifted post facto. You can have unsafe code that assumes it knows the set of types uses — but that should be an unsafe trait and can't become unsealed anyway. You can have an extension trait which should be able to gain new non-defaulted methods[1], but unsealing that trait is also fairly pointless.

Maybe I've missed a use case for sealed traits, but I can't think of any which can go away in an API preserving manner beyond wanting to emulate overloading without making the uses an extension point. And if the sealed trait is coherence impacting, you can always "just" make a new trait which is blanket implemented from the sealed trait for the use sites, which is almost as good as just removing #[sealed]. (You might even be able to get away with swapping it in-place, depending on how exactly the coherence relaxation is defined.)

(I should go classify the times I've used/seen the pub-in-priv sealing pattern...)


If coherence implications are limited to the defining crate, though, I do agree that we can add the implications on later, as removing #[sealed] would be either a nonbreaking change or a local error.

I do think being able to blanket impl based on a sealed trait without blocking downstream implementations is useful, but am happy enough to drop it if we have an evolution plan that doesn't run into "what's the difference between #[sealed] trait and sealed trait?" (And it's worth noting that sealed does not mean a finite nor fixed set, as the sealed trait impl could be a blanket impl off of an open trait, so static trait is not a great option for the "Wrapping case," though it works well for trait PrimInt specifically.)


  1. workaround: as_concrete method family to get the concrete type, then the new methods can be defaulted and not require a sealed trait ↩︎

1 Like