[~OT] Go generics

I've just seen a page with a summary of the new design of Go generics:

It's about this longer document (that I haven't read): https://go.googlesource.com/proposal/+/refs/heads/master/design/go2draft-type-parameters.md#type-parameters-draft-design

I've seen that there's a simple way to enumerate types that safisty an interface:

type Ordered interface {
  type int, int8, int16, int32, int64,
    uint, uint8, uint16, uint32, uint64, uintptr,
    float32, float64,
    string
}

func Max(type T Ordered)(elems []T) T {
  if len(elems) == 0 {
    var zero T
    return zero
  }

In some cases I'd like to do something like that in Rust, without the usage of much less nice macros.

I believe this would fall under sealed traits? (I.e. a trait that cannot be implemented outside the current crate, and coherence can rely on that fact.)

I've not looked at Go's design in detail, but it looks like the enumerated interface just allows you to do whatever operations all of the types support? I strongly doubt Rust will ever grow a duck-typed interface system, even for something as local as an enumerated trait, and always require listing the functionality a trait provides.

That said, it should be fairly trivial to write an attribute proc macro today that allows something close:

#[enumerated_trait(
    isize, i8, i16, i32, i64, usize, u8, u16, u32, u64,
    f32, f64,
)]
trait Trait: Ord {
    fn max_value() -> Self;
}

Which expands to something like

trait Trait: __unnamable::Sealed + Ord {
    fn max_value() -> Self;
}
#[forbid(unconditional_recursion)]
mod __unnamable {
    use super::*;
    trait Sealed {}
    impl Sealed for isize {}
    impl Trait for isize {
        fn max_value() -> Self { Self::max_value() }
    }
    // and so on, delegates methods to intrinsic functionality
}
2 Likes

You could also just use an enum – after all, that's the very purpose of enums, a closed set of types. A trait in Rust is instead suitable for defining an open set of types satisfying some criteria – I see no value in mixing the two.

3 Likes

Sealed traits are still useful for things like dynamicism in Arc<dyn Trait>, or monomorphizing over a closed set of types.

Yes, ADT enums are the better solution for a lot of cases (e.g. most uses of sealed class in Kotlin are specifically as an ADT enumeration), but there are still use cases where the dynamicism of a trait is desirable.

7 Likes

Well, now that I finally got the time to read through it, here's the inevitable "Rust is different so Go's arguments don't apply here" post, and it's simpler than I expected: The whole reason this generics proposal introduces type lists is to support being generic over operators. Here's the section laying out that argument. But in Rust, nearly every operator is effectively sugar for a trait method, so they pose no special challenge for Rust's generics. So I agree this doesn't really move the needle on how desirable "sealed traits" would be for Rust.

But as far as Go is concerned, I like this proposal a lot more than the previous one that had to invent a whole constraint subsystem to be as generic as they wanted it to be. This feels much closer to the lang-complexity/feature-power tradeoff Go usually shoots for.

Personally, I find it hard to read generics that use the same parenthesis as every other list-like syntactic construct in the language, but I get that Go wouldn't want to introduce a turbofish just to make angle brackets feasible, so I dunno.

5 Likes

This actually reminds me of my -> enum impl Trait proposal from about a year ago. Abridged, it's something like this:

fn foo(cond: bool) -> enum impl Debug {
    if cond {
        42
    } else {
        "foo"
    }
}

The above generates an enum with the obvious delegating implementation of Debug. Of course, this does not work with traits with unbound associated types or which accept or return a type that contains Self...

I am pretty sure that if Rust got anonymous types, the signature of foo would be something like.

fn foo(cond: bool) -> (i32 | &'static str);

Which open some really interesting design idea:

fn foo(cond: bool) -> impl Debug {
    // Automatically create an enum with two variant, so the value returned
    // by both branch of the `if` don't need to be the same.
    // Anonymous enum would implements all the trait implemented by all
    // the variants, so this anonymous variant would implement `Debug`.
    let ret: (i32 | &'static str) = if cond {
        42
    } else {
        "foo"
    }
    ret
}

And the compiler should probably at this point be smart enough to understand this:

fn foo(cond: bool) -> impl Debug {
    if cond {
        42 as i32
    } else {
        "foo"
    }
    // the type created by this `if` statement is an anonymous enum
    // of (i32 | &'static str). Both variants implements `Debug`, so the
    // anonymous enum would also implement `Debug`.
}

For maintainability I would really like to know the rough gist what a function does from its signature. The more magic there is, the more I need to analyze the code to see what it really does.

Furthermore, automagically instancing enums this way would give rise to inadvertent typos or incomplete refactorings still being accepted by the compiler, now instancing an enum where a singular concrete type was intended, leading to puzzling error messages or outright bugs.

Additionally, this creates an exceptionally weak contract: all I really can do with the function is use the returned value for debugging output, nothing else, or risk breakage when internals are changed.

Given that this is not true for tuples, I'm not sure you should expect it to work with arbitrary sum types, either...

Uh. The function signature tells you everything you need to know. It returns one of many unspecified types that all implement a particular trait, wrapped up in an enum instead of using a vtable (like Box<dyn> would).

I just used Debug because it was the first trait that came to mind that satisfied the constraints I described. You could imagine enum impl Iterator<Item=...> working just as well.

While I like the idea of anonymous enums (my proposal), I would strongly prefer to be explicit about type conversions. Your last example could look like this instead:

fn foo(cond: bool) -> impl Debug {
    if cond {
        become 42 as i32
    } else {
        become "foo"
    }
}

Why not enum impl Trait + implicit conversion? I think anonymous enum is just an implementation detail, so it should not be expressed in function signatures.

2 Likes

Oh, the surface syntax is irrelevant in my opinion. I think something like what you wrote is an equally reasonable approach.

I totally agree with both the expliciteness of become and -> impl Debug.