Origins in Type Equality

A discussion that happened today on discord between @sora, @martinvuyk and I. There’s a few problems with origins that have cropped up that I think bear some deeper discussion.

The first is that, since the origin is part of the type, it greatly limits a lot of traits. For instance, StringSlice doesn’t implement EqualityComparable because EqualityComparable requires the types to be the same. I think it’s somewhat likely that many, many people will write traits assuming that making multiple arguments be typed as Self, which leaves us with an issue, since those traits won’t be able to interact with origins outside of the most trivial cases. To me, this means that we need a way to make this easy to do. Consider a type Tensor[rank: UInt, origin: MutableOrigin], if I were to implement normal, boolean equality on it, I would want to constrain the ranks to be equal, but I don’t care about the origins. Right now, the traits, for lack of a better term, capture too much. We can fix some of this with parametric traits, but I think we may need a “don’t care” for parameters. For example (bit of Rust-flavored syntax)

trait EqualityComparable[T]:
  fn __eq__(read self, read other: T) -> Bool:
    ...

impl[rank: UInt, origin1: ImmutableOrigin, origin2: ImmutableOrigin] EqualityComparable[Tensor[rank, origin]] for Tensor[rank, origin2]:
  fn __eq__(read self, read other: Tensor[rank, origin]) -> Bool:
    ...

This is fairly cumbersome, but something that I would expect to work at some point in the future.

trait EqualityComparable[T]:
  fn __eq__(read self, read other: T) -> Bool:
    ...

impl[rank: UInt] EqualityComparable[Tensor[rank]] for Tensor[rank]:
  fn __eq__(read self, read other: Tensor[rank]) -> Bool:
    ...

This version allows me to ignore parts of the type that this function doesn’t care about. The user only writes the parts of the type they want to “match on”. Now, I have no idea how the trait solver works internally, so I’m working off of some very uneducated guesses as to how I would write one here. My guess is that, since the trait solver has to handle things like wildcard implementations for variadic types at some point, it’s demand-based, meaning that when a parametric trait is implemented for a type, it doesn’t actually try to fill out the entire cross product of input types to serve up when needed, and instead stamps out an implementation when something needs it. My question is whether it would be possible to allow that to match with unbound fields, filling in whatever data is available from the demand, and enabling users to “not care” about anything that isn’t specified. This would also help with the problem of default parameters needing to be overridden, since it forces trait authors to handle them or only rely on the intersection of all possible values of that parameter. This does mean that types would act a bit different in traits vs in normal code, so that does bring context-dependent grammar hazards.

@sora also pitched the idea of “erasable parameters” as a way to do this explicitly,

The conversation then got a bit sidetracked into an adjacent issue. Right now, we need to have our collections decide whether they store values or references. Due to type requirements, List[Pointer[T]] can only have origins which are available at the time the list is created, which is a problem for long-lived collections, and for vectored IO, which for tasks like serialization typically involves building a List[Span[UInt8, _]] into some data strucuture so that you avoid copying it all into a single buffer.

Both of these examples have one big thing in common, the problems go away if origins no longer participate in type equality. I’d like to open discussion on whether that is a good idea.

cc: @clattner

5 Likes

Hi Owen,

I just noticed this thread again, I simply haven’t had headspace to think about this recently, I’m sorry!

I do agree that origins are a special/weird sort of parameter - they expand to nothing at all at runtime, so perhaps they could get special behavior? I haven’t had time to think about it clearly.

If I were to magic a zig-style reflection API (comptime) into Mojo, would it make sense to offer a

trait TypeEqualityComparable:
    @staticmethod
    fn __eq__[U: UnknownDestructibility]() -> Bool:
      ...

Where the reflection API is used to pick out what parts of the type you do and don’t care about? Or, would that have horrible consequences for type checking performance.

I want to avoid special behavior unless we really need it, but perhaps the concept of a “compile type only” type where the type itself could decide whether to participate in normal type equality, with constrained being used if the user does want that behavior. This would also allow us to say “all StringLiteral instances are the same”, and similar with IntLiteral.

I’ll let you think on it more, just some more food for thought.