Soliciting use-cases for closed sets of types

A Rust trait describes an "open set" of types—the language feature is designed so that you can add a new impl of Trait without breaking existing code. This makes them an awkward fit for cases where you really want a closed set of types, like {f32, f64} or {u8, u16, ..., u128} (to take two examples that are relevant to the num libraries). We often recommend using either a sealed trait or an enum to express polymorphism over a closed set of types, but I'm not happy with either of those approaches, and lately I've been toying with another design that I hope will be a better fit.

(I don't currently have any plans to propose a change to Rust, this is mostly an intellectual exercise.)

To sanity-check my design, I'd like to gather lots of examples of situations where people want to work with a closed set of types and of the best existing solutions they've found. If you have such examples—from questions you've answered or asked on this forum or elsewhere, from your own projects, whatever—please post them here!

Errors?

2 Likes

The only time I've relied on closed sets of types was when I did heavy type-level computation. If your entire world is the set of types 𝒮, then you can implement the (normally impossible) bound TU as T ∈ 𝒮∖U instead.

For more developed examples of this kind of thing, look at typenum, which relies heavily on bits being in the closed set {B0, B1}, and frunk, which relies on the tail of an HCons<H,T> to be either HCons<...> or HNil.

1 Like

This could be useful when interacting with some external protocols. For example, in X protocol there's a Drawable type, which is effectively an untagged union of two other types, Window and Pixmap (which are both represented as just u32). For now, we have to either leave the type only in documentation and use plain u32s everywhere (that's essentially what x11rb does currently - they have the type aliases, but that's all), or include some explicit conversions whenever we need to use e.g. Window as Drawable. There's a recently started discussion on this, in fact; it probably can lead to some useful insights.

1 Like

I'm writing a game where the game state consists of a graph where the nodes can be one of several types of objects. This is a non-extensible set of types because there is no provision to store an arbitrary node type, or advance time in proper synchrony for arbitrary node types; and each edge is also required to be a specific type, not any node type. So, for manipulating the graph, it is useful to define generic but non-extensible operations and handle types.

So far, I have handled this by exposing sealed traits and generic types, using several enums internally, and in one place using Any internally (where I need to generically collect values of an associated type of one of the traits).

The most straightforward (in concept, not to implement) language improvements I can think of to clean this up would be:

  • Simple explicit sealed traits
  • "Make an enum out of this generic type whose variables are all bounded by sealed traits"
  • Possibly the opposite: presenting an enum as if it were a generic struct bounded by a sealed trait.
1 Like

tbh, if the compiler had a builtin #[sealed] attribute it would satisfy 90% of my needs for a "closed set of types".

In the meantime, I just make the trait unsafe because usually the only reason I need to limit who implements a trait is because I'm writing unsafe code and I need to restrict things to prevent UB.

For a concrete example, in WebAssembly the only things you can pass from the host to the guest are integers, floats, special known types (e.g. memory), and function pointers... That means there is no defined behaviour for passing around more "advanced" types like struct Person { age: u32 }, so if I want to create higher level abstractions which work with these functions I will need to constrain the arguments/return values somehow.

Traits are a useful abstraction and I'd like to keep building on them, it's just that I sometimes might not want Rust to be so liberal in what is allowed to implement a trait.

2 Likes

This is interesting! I assume x11rb has some code that is "generic" over any Drawable, right? Does that code have to dispatch to different low-level calls depending on whether we have a Window or a Pixmap, or are these handled uniformly under the hood?

Well, there's no "genericness", since Drawable is just an u32, which doesn't know what kind of drawable it is. But this is unnecessary, since, if the function is described as taking the Drawable, then the difference between drawables is handled by the X server, the request is exactly the same in any case.

1 Like

Thanks, this is just the kind of thing I'm looking for. Could you say more about why you decided to use sealed traits in the public interface and enums internally? Are you managing the transition between these using an <T: Into<MyInternalEnum>> kind of pattern, or something else?

This is what I was trying to get at—thanks!

Could you say more about why you decided to use sealed traits in the public interface and enums internally?

The majority of the public interface is statically typed. That is, if a Foo can refer to a Bar then this is represented as something like

struct Foo {
    bars: Vec<URef<Bar>>,
}

with appropriate public methods. Thus, an enum should not appear here because only Bar is valid.

On the other hand, internally, the machinery for operations that (potentially) apply to the whole graph needs to work on nodes of different types, and enums are not strictly necessary but often the most practical solution. For example, suppose I want to work on a collection of things related to the nodes. If Rust had higher-order type variables, then I could write things like

struct MapForEachType<F> {
    foos: HashMap<Key, F<Foo>>,
    bars: HashMap<Key, F<Bar>>,
}

but Rust does not have that feature, so in practice when this comes up I use a single HashMap containing an enum with variants Foo(F<Foo>), Bar(F<Bar>), for whatever F concretely is — or a dyn Any if I externally know what the type should be and I don't need Eq or Clone or other such operations that can't be used with Any.

Are you managing the transition between these using an <T: Into<MyInternalEnum>> kind of pattern, or something else?

No, because that would require me to expose the enum and there are several enums for different situations. Instead, the graph container implements a trait for each type of thing it can contain, and everything public depends on either that or concrete parameterized types (as in the URef<Foo> example above).

I don't claim that this is a fully general solution, or the best solution for each case. Rather, I've been solving problems as they come up.

1 Like