Pre-RFC: Sealed traits

#23

With https://github.com/rust-lang/rfcs/pull/2028 something like this will be available as well:

pub trait MyThing {
    fn public_method(&self);

    pub(self) fn private_method_that_cannot_be_implemented_outside_of_this_module_making_the_trait_sealed();
}
1 Like
#24

Unfortunately, no: https://is.gd/wfpcIp

#25

Isn’t unsafe trait (also) an appropriate solution to this?

#26

Despite the fact that it seems like a hack or bug and not an actual language feature I guess I would choose this if I absolutely don’t want anyone implementing my trait and/or I have private methods/objects I also don’t want to expose (defined/implemented on Sealed).

The way I understand unsafe trait is that it can still be implemented by API users, but at least it requires an unsafe opt in. Would also potentially expose methods/objects that I might only want to be private. So it’s not an exact drop-in replacement for #[sealed]

#27

Using unsafe makes it more like a social contract than a compiler-enforced one.

2 Likes
#28

Using unsafe as a makeshift sealed traits feature also dilutes the “here be Segmentation Fault” meaning of unsafe by provoking creation of perfeclty safe (with respect of memory safety) crates that do nonetheless contain The Dreaded Unutterable unsafe (and can’t work under #[forbid(unsafe_code)] and don’t get hypothetical “I’m unsafe-free” crates.io badge ).

3 Likes
#29

(I was responding specifically to @TheDan64’s use case. The whole point there is that downstream crates shouldn’t declare any unsafe impls because it would be memory-unsafe. I appreciate that sealed traits would be even more airtight, but the specific problems you cite don’t really apply.)

#30

Something for which compiler support for sealed traits would be highly useful would be non-public methods in sealed traits.

For instance, I currently want to have a marker trait used in my library that’s used to restrict generic functions to some traits from my library. But these generic impls must now depend only on the interface of the marker trait, which means it has to export enough functions to be usable from said generic functions. And so these functions become part of the public API.

In code, here is what I’d like:

#[sealed]
trait Trait {
    pub(crate) fn foo(&self);
}

struct Struct {}
impl Trait for Struct { fn foo(&self) {} }

fn bar<T: Trait>(t: &T) { t.foo() }

Does what I’m hoping for make sense? Currently the best way to do it appears to be #[doc(hidden)]… or defining a private struct in my library and taking it as an argument.

I don’t see any way other than sealed traits to do this cleanly, as pub(crate) would make no sense for non-sealed traits.

#31

@Ekleog have no hard opinions on #[sealed], but as I understand it trait methods are currently always public no matter what, so the pub(crate) for method foo would require a further language change at the very least.

#32

I think another thing to note is that sealed traits allow for a more gradual shift from enums to traits and vice versa.

Extendable from outside Can be used as Constraints
enum No No
sealed traits No Yes
traits Yes Yes

It seems like the perfect fit for something between enums and traits.


On this note, trait objects of sealed traits don’t need to store a vtable and perform dynamic dispatch, they could just store an index which specifies which type they are and use that to figure out which impl to call, thus making them effectively as fast as enums, while still being a trait!


~edit~

Also on the note of using indices to differentiate types, this could be used to provide a way to downcast references into their concrete types for all sealed traits.


~edit 2~

Indexes cannot be used in general due to how amazing generics are.

1 Like
#33

FWIW, Kotlin sealed classes are basically this approach to sealed traits, though also being the “data carrying enumerated type” and using the JVMs casting support still.

I wonder if a proc macro could be written to implement this in user code… it would also basically be equivalent to “enum variants are types” while the restriction of implementors being in the same file (macro block) holds.

(I do not have time and should not implement this but)

sketch
#[enum_trait]
pub trait Example {
    Case1(String),
    Case2 {
        alpha: usize,
        beta: isize,
    }
}

// translates to

mod $unnamable {
    pub trait Sealed {}
}

pub trait Example: $unnamable::Sealed {}
pub enum dynExample {
    Case1(Case1),
    Case2(Case2),
}

pub struct Case1(pub String);
pub struct Case2 {
    pub alpha: usize,
    pub beta: isize,
}

impl Example for Case1 {}
impl Example for Case2 {}

// plus handle conversions via (Try)Into
// and other derives as well

But having written that, I like the idea of having sealed traits’ vtable instead be an index, so dyn SealedTrait is just the regular struct and the fat pointer has the enum discriminant.

The comparison to “enum variants as types” stands, though.

#34

That was exactly what i was thinking of, just couldn’t remember the phrase.

I love that this could be the bridge between enums and traits.

#35

Another thing we could guarantee about the sealed traits is that dyn Trait: Sized where Trait is a sealed trait and that dyn Trait is laid out exactly the same as an enum with each variant of the enum hold exactly one of the types. This could be huge as it will enable ergonomic error handling. This doesn’t dilute the meaning of dyn, it still means dynamically dispatched, it just changes how it is dispatched. Also with this, sealed traits can be object safe even if they have consuming functions (i.e. functions that take self). no we can’t, generics don’t allow it.

ignore rest of this post

For example (simple example)

trait CustomError {  }

struct DivByZero;
struct SqrtOfNegativeNumber;

impl CustomError for DivByZero {}
impl CustomError for SqrtOfNegativeNumber {}

fn fallible(x: f32) -> Result<f32, dyn CustomError> { // note: dyn CustomError would be Sized
    if x > 0.0 {
        Ok(1.0 / x.sqrt())
    } else if x == 0.0 {
        Err(DivByZero)
    } else {
        Err(SqrtOfNegativeNumber)
    }
}

And this could just work! It is easily extensible and the users of this code can just use the interface provided by the trait (this is a lot like how Pre-RFC: sum-enums works, but more useful).

#36

I really like this formulation, and it probably deserves an RFC of its own. Given the large semantic impact, it probably should have dedicated syntax instead of just an attribute… enum trait maybe, which I think captures the benefit of using it.

It’s kind of impressive how many use cases this can serve, especially if it offers a nice way to downcast. While it’s not as ad-hoc as anonymous enums or tagged unions, it offers clear subsetability.

I think you’ve convinced me that dyn EnumTrait : Sized is better than sticking the enum discriminant in a fat pointer. That’s something we’d have to weigh pros/cons of in the RFC.

PM me if you’d like to collaborate on drafting an RFC. This is definitely something I’d like to see fleshed out further.

#37

I’ll start a new thread here on internals, and we can flesh it out there.

This Pre-RFC (Take 2) is closed.

Here it is:

#38

Some prior art for sealed traits (“closed classes”) in the context of Haskell: https://www.microsoft.com/en-us/research/publication/object-oriented-style-overloading-for-haskell/

(I had a comment on one of Niko’s blog posts a few years ago exploring the connections, but it looks like all comments are gone now…)

1 Like
#39

I think we could do something like dyn SealedTrait: Sized as an optimization.

We could enable it with something like

#[derive(SizedTraitObject)] // bikeshed
seal trait SealedTrait {}

This would enable some checks

  • All implementers are Sized
  • All generic parameters that are used on a struct in the impl are used in the trait. i.e
struct Err<E: Error>(E);
struct DatabaseError;

// Valid

#[derive(SizedTraitObject)]
seal trait CustomError_EX1 {}

impl CustomError_EX1 for Err<std::fmt::Error> {} // all generic parameters are filled, so this is fine
impl CustomError_EX1 for Striing {} // no generic parameters, so this is also fine

#[derive(SizedTraitObject)]
seal trait CustomError_EX2<E> {}

impl<E: Error> CustomError_EX2<E> for Err<E> {} // fine, all generic parameters used on struct are used on CustomError_EX2
impl<E> CustomError_EX2<E> for String {} // fine, String has no generic parameters

// Invalid

impl<E: Error> CustomError_EX1 for Err<E> {} // the type parameter E is not constrained by CustomError_EX1, so this is invalid

impl<T, E: Error> CustomError_EX2<T> for Err<E> {} // the type parameter E is not constrained by CustomError_EX1, so this is invalid

This is similar to how enums currently work.

But at that point you could just use enums, where each variant holds a single type, and has a From impl for each variant for ergonomics.

#40

I wonder if this “enum trait” (dyn Enum : Sized by treating it as an enum) could work for unsealed traits as well? (Though it’s kind of off topic in a thread about sealed traits :laughing:) Ofc a dylib makes it break, and introducing features that don’t work in dylib is a big bummer we’d like to avoid, but in a statically compiled binary, you know all the implementors, and you could enforce the unconstrained type parameters on implementors either way.

Probably solidly in the not-really-worth-it idea bin.

#41

Yeah, that would be nice, but it is solidly in the not “not-really-worth-it idea bin”.

closed #42

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