Different styles for sealing traits

Is the difference between using a sealed supertrait as in this post rather than a hidden method as done here just a matter of public visibility? Is there any reason to use one instead of the other?

3 Likes

In the chart from your first link,

downstream code can use it as a bound downstream code can call its methods downstream types can impl it
pub trait :white_check_mark: :white_check_mark: :white_check_mark:
supertrait sealed trait :white_check_mark: :white_check_mark: :x:
method signature sealed trait :white_check_mark: :x: :x:
private trait :x: :x: :x:

rayon is using the third case here, an unimplementable method. The difference from a supertrait is the diagnostics when trying to implement the trait and the fact that downstream crates are capable of naming/calling the __rayon_private__ method.

The sealed method signature is also a lot less effort to use for the providing crate, since it's just another method to implement on each impl, as opposed to needing to write each impl header twice, once for the actual impl and once for the unnameable supertrait (which needs to be unique per sealed trait, whereas the unnameable type for the method approach can be shared).

I find the unnameable supertrait to generally be a better experience for downstream consumers, but it is a not insignificant extra amount of effort if you have a number of sealed traits.

3 Likes

Yes it's using a method signature to seal the trait, but just by adding a static macro-declared and hidden no-op method. It even states it is for the purpose of preventing downstream implementation of the trait, not to make that one useless macro-declared no-op method uncallable (though it is).

pub trait SealedTrait { // SealedTrait: private::Sealed {
    type NameableType;

    fn callable_method(&self) -> Self::NameableType;

    // This would seal a method which actually does something
    fn not_publicly_callable(&mut self, _: private::PrivateToken);

    // Adds a hidden, no-op method that does not change the rest of the trait
    private_decl!()
}

The notes on downstream experience make sense for the visible version. But both styles are just one extra line per implementation; either

impl private::Sealed for SealedType {}

or private_impl!() within the trait implementation body.

How hashbrown/rayon does it just struck me as more initial setup effort to do effectively the same thing.

Actually, I don’t think it makes the method uncallable: the only argument of the "sealing method" is &self. However, in rayon, all sealed traits are "pub-in-private", meaning that we can’t use the trait and name it. That’s what is effectively preventing the user from explicitly relying on this trait in their code. In fact, this fact alone is already effectively sealing the trait: users can’t implement traits they can’t name. Like demons: no name, no power. They can’t even use it as a bound because of this.
To clarify: this is not a "method signature sealed trait" as described in the blog post.

In this case, I think that:

  • They sealed the traits "twice" as a precaution, just in case a trait ends up non-pub-in-private by mistake in the future.
  • It’s just a matter of taste, pub trait SealedTrait: private::Sealed is more or less equivalent. One could argue that the private::Sealed trait approach is superior for reasons already stated in this thread (and the fact that it actually does leave a callable dummy method behind), but I don’t think it matters much, especially here.

As a disclaimer: that’s really based on my understanding, and I hope I didn’t miss an important detail.

Oh yeah, now it seems even more odd that it's a hidden but I guess otherwise callable method, rather than sealed by requiring a PrivateToken argument.

Indeed! I've been using a private::Sealed supertrait and thought it was quite expressive and succinct. But then I stumbled across the private_decl and private_impl macros in the Data traits in ndarray, tracked down their actual implementation, and then wondered if I was missing something obvious and important.

Note that if you have multiple sealed traits, and one of them has a type parameter, then sharing one sealing supertrait between them can open a gap for downstream implementations:

/// Note: to demonstrate conclusively, this would have to be a separate
/// crate, not just a module, so that trait coherence rules are enforced.
mod my_lib {
    mod private {
        pub trait Sealed {}
    }

    pub trait Foo: private::Sealed {}
    pub trait Bar<T>: private::Sealed {}

    pub struct FooExample {}
    impl private::Sealed for FooExample {}
    impl Foo for FooExample {}

    pub struct BarExample {}
    impl private::Sealed for BarExample {}
    impl Bar<()> for BarExample {}
}

struct Local;
/// This is allowed even though `Bar` is a sealed trait.
impl my_lib::Bar<Local> for my_lib::FooExample {}

The unimplementable method has the advantage of avoiding this subtlety (though I was not previously aware of that technique so I can't comment on its own disadvantages).

4 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.