Skip to content

new type of enum #3623

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
IoaNNUwU opened this issue May 3, 2024 · 8 comments
Closed

new type of enum #3623

IoaNNUwU opened this issue May 3, 2024 · 8 comments

Comments

@IoaNNUwU
Copy link

IoaNNUwU commented May 3, 2024

Add new type of enum to simplify handling enums with existing types as its variants.

Example:

type enum NumOrString {
    i32, String
}

let value = NumOrString::from(10_i32); // From<> variants implemented automatically
let value: NumOrString = 10_i32.into();

match value {
    i32 => println!("Num: {value}"), // value changes its type to i32 for this arm.
    String => println!("String: {value}"),
}

This will dramatically simplify things like this:

// Packets
#[derive(Serialize, Deserialize)]
struct Handshake
#[derive(Serialize, Deserialize)]
struct SomeMessage(pub i32)
#[derive(Serialize, Deserialize)]
struct AnotherMessage(pub String)

type enum Packet {
    Handshake, SomeMessage, AnotherMessage
}

let packet: Packet = SomeMessage(10).into();

match packet {
    Handshake => println!("Hello !"),
    SomeMessage(num) => println!("Some message {num}"),
    AnotherMessage(text) => println!("Another message {text}"),
}

Instead of currently available:

// Packets
#[derive(Serialize, Deserialize)]
struct Handshake
#[derive(Serialize, Deserialize)]
struct SomeMessage(pub i32)
#[derive(Serialize, Deserialize)]
struct AnotherMessage(pub String)

// A lot of repetition for each variant
enum Packet {
    Handshake(Handshake), 
    SomeMessage(SomeMessage),
    AnotherMessage(AnotherMessage),
}

let packet = Packet::SomeMessage(SomeMessage(10));

match packet {
    Packet::Handshake => println!("Hello !"),
    Packet::SomeMessage(SomeMessage(num)) => println!("Some message {num}"),
    Packet::AnotherMessage(AnotherMessage(text)) => println!("Another message {text}"),
}

This will also make possible of more algebraic data types, such as:

type enum OneOf<E1, E2> {
    E1, E2
}

Instead of currently possible:

enum OneOf<E1, E2> {
    E1(E1),
    E2(E2),
}

Types like this work perfectly with Result:

fn add_and_divide(a: i32, b: i32) -> Result<i32, OneOf<IntegerOverflow, DivByZero>> { .. }

fn main() {
    let res = add_and_divide(1, 2);
    match res {
        Ok(num) => println!("{num}"),
        Err(error) => match error {
            // we can directly match over types, no need for E1(integer_overflow) => { .. }
            IntegerOverflow => println!("{:?}", IntegerOverflow),
            DivByZero => println!("{:?}", error), // in this arm error has type DivByZero.
        },
    }
}

Instead of currently possible:

fn add_and_divide(a: i32, b: i32) -> Result<i32, OneOf<IntegerOverflow, DivByZero>> { .. }

fn main() {
    let res = add_and_divide(1, 2);
    match res {
        Ok(num) => println!("{num}"),
        Err(error) => match error {
            // having E1(name) is ugly
            E1(overflow) => println!("Error: Overflow happened: {overflow:?}"),
            E2(div_by_zero) => println!("Error: Division by zero: {div_by_zero:?}"),
        },
    }
}

Or:

enum AddAndDivideError {
    // you may think having error inside variants of this enum is pointless, but 
    // lets imagine IntegerOverflow & DivByZero are some library types and they
    // implement debug which we want to use
    IntegerOverflow(IntegerOverflow),
    DivByZero(DivByZero),
}
fn add_and_divide(a: i32, b: i32) -> Result<i32, AddAndDivideError> { .. }

fn main() {
    let res = add_and_divide(1, 2);
    match res {
        Ok(num) => println!("{num}"),
        Err(error) => match error {
            IntegerOverflow(overflow) => println!("Error: Overflow happened: {overflow:?}"),
            DivByZero(div_by_zero) => println!("Error: Division by zero: {div_by_zero:?}"),
        },
    }
}

Additional things to consider:

Named variants in match:

type enum NumOrString { 
    i32, String 
}
match value {
    num: i32 => println!("Num: {num}"), // value is unavailable here in favor of num
    text: String => println!("String: {text}"),
}

Imports

Because type enum uses types themselves in match, we have to have a reliable way to access them.

Solutions might be:

  • Allow this only for the types declared in the same crate
  • Allow this only if we reexport all needed types in the same crate

Different ways to create

let value = NumOrString::from(10_i32);
let value: NumOrString = 10_i32;
let value = 10_i32 as NumOrString;

Anonymous structs if user wants to create new type associated with this type enum:

struct Handshake;
struct Message1;
struct Message2;

type enum Message {
    Handshake,
    Message1,
    Message2,
    // those variants are the same as if declared in standard enum
    struct Interrupted(u32),
    struct MyMessageType(u32, u32),

    // This is currently impossible to have enum as part of enum (see [1]):
    // so this should probably be disallowed too for consistency
    enum ConnectionLost {
        ServerSide, ClientSide
    }
    // also type enum could be part of another type enum if enums in enums are allowed
}

let msg = Message::from(Handshake);

match msg {
    // we can match on type itself
    Handshake => println!("Hello"),
    // we can also match on associated structs
    Message::Interrupted(code) => println!("Interrupted: {code}"),
    // we should probably be disallowed to have enums as part of enums, but if we are allowed:
    Message::ConnectionLost::ServerSide => println!("Something went wrong on server"),
    else => {},
}

[1] This is currently impossible to have enum as part of enum:

enum Message {
    Handshake,
    SomeMessage(u32),
    AnotherMessageEnum { 
        Server, Client // doesn't compile - we have to use wrapper structs for enums
    } 
}

This doesn't compile - we have to use wrapper structs for enums

enum Message {
    Handshake,
    SomeMessage(u32),
    AnotherMessage(AnotherMessage)
}

enum AnotherMessage { 
    Server, Client    
}
@ChayimFriedman2
Copy link

Anonymous enums/structs are a separate feature (discussed many times before) and should be tracked separately.

Automatic From implementation can easily be done with a derive macro.

So, other than somewhat shorter syntax, what are this syntax's benefits?

Considering that Rust is already a complex language, and this doesn't add much new power/commonly requested feature, it seems better to save our complexity budget for other features.

@kennytm
Copy link
Member

kennytm commented May 3, 2024

is this just #294 but with a name? the same sort of unresolved problem then

type enum OneOf<E1, E2> {
    E1, E2
}

fn which<E1, E2>(a: OneOf<E1, E2>) -> usize {
    match a {
        E1 => 1,
        E2 => 2,
    }
}

let f = OneOf::<i32, i32>::from(1_i32); // ?
let w = which(f); // ?
assert_eq!(w, 1); // ????
let g = OneOf::<&i32, String>::from("foo".to_string());
match &g { // can it interact with match ergonomics?
    &str => println!("string {}", g),
    i32 => println!("int {}", g),
}

@IoaNNUwU
Copy link
Author

IoaNNUwU commented May 3, 2024

let f = OneOf::<i32, i32>::from(1_i32); // ?
let w = which(f); // ?
assert_eq!(w, 1); // ????

I guess I didn't make it clear, but obviously you cannot create OneOf with 2 variants being of same type, because by definition, each variant has to be of its own unique type.

I also do not propose the addition of OneOf in the std, but making this type possible. Crucial difference is the way you think about the types you are using.

Consider this:

enum OneOf<E1, E2> { E1(E1), E2(E2) }

fn add_and_divide(a: i32, b: i32) -> Result<i32, OneOf<IntegerOverflow, DivByZero>> { .. }

fn main() {
    let res = add_and_divide(1, 2);
    match res {
        Ok(num) => println!("{num}"),
        Err(error) => match error {
            E1(overflow) => println!("Error: Overflow happened: {overflow:?}"),
            E2(div_by_zero) => println!("Error: Division by zero: {div_by_zero:?}"),
        },
    }
}

E1(overflow), E2(div_by_zero) make it clear you're using this specific enum. If you were to change return type of add_and_divide to, for example, AddAndDivideError, with IntegerOverflow(IntegerOverflow) and etc. variants, you have to change code in the main function too (From E1(overflow) to IntegerOverflow(overflow)), but type enum hides this indirection because you're only using final error types like so:

type enum OneOf<E1, E2> { E1, E2 }

fn add_and_divide(a: i32, b: i32) -> Result<i32, OneOf<IntegerOverflow, DivByZero>> { .. }

fn main() {
    let res = add_and_divide(1, 2);
    match res {
        Ok(num) => println!("{num}"),
        Err(error) => match error {
            IntegerOverflow => println!("Error: Overflow happened: {error:?}"),
            DivByZero => println!("Error: Division by zero: {error:?}"),
        },
    }
}

Now we can change function add_and_divide to use another type as error, for example:

type enum AddAndDivideError { IntegerOverflow, DivByZero }

fn add_and_divide(a: i32, b: i32) -> Result<i32, AddAndDivideError> { .. }

fn main() {
    let res = add_and_divide(1, 2);
    match res {
        Ok(num) => println!("{num}"),
        Err(error) => match error {
            IntegerOverflow => println!("Error: Overflow happened: {error:?}"),
            DivByZero => println!("Error: Division by zero: {error:?}"),
        },
    }
}

And code in main functions doesn't need to change. We're getting better error handling with all errors being explicit and necessary to handle, so you can think about errors on higher level without the need of thinking about what enum they're actually represented with.

I also don't think this is niche use case, because enums play a huge role in Rust ecosystem, and often each variant has associated data of different types (primarily being wrapper structs), which is great use case for this.

I also don't think this adds much complexity, it may even simplify a lot of things, but I understand that those are primarily syntax changes.

@kennytm
Copy link
Member

kennytm commented May 3, 2024

but obviously you cannot create OneOf with 2 variants being of same type, because by definition, each variant has to be of its own unique type.

What is the meaning of "same type"? Are &'static str and &'a str the same type? &'a str vs &'b str? for<'a> fn(&'a str, &'a str) vs for<'a, 'b> fn(&'a str, &'b str)? An impl Trait vs its concrete type? Two impl Traits?

I also do not propose the addition of OneOf in the std

No one mentioned std

I also don't think this is niche use case, because enums play a huge role in Rust ecosystem, and often each variant has associated data of different types (primarily being wrapper structs), which is great use case for this.

Sure it is not a niche use case given the number of duplicates of #294, the problem is how it fits into the type system. The main complexity here is the type-match expression.

@SOF3
Copy link

SOF3 commented May 3, 2024

Am I missing something, or can the proposed syntax already be implemented through attribute macros?

@IoaNNUwU
Copy link
Author

IoaNNUwU commented May 4, 2024

What is the meaning of "same type"? 

I think the meaning is types indistinguishable in match.

For example currently you cannot use named lifetimes in patterns so types &'a i32 and &'b i32 are indistinguishable.

fn do_sm<'a, 'b>() {
    let n = 10;
    let ref1 = Refs::R1(&n);
    
    match ref1 {
        Refs::R1(&'a n) => {},
        Refs::R2(&'b n) => {},
    }
}

enum Refs<'a, 'b> {
    R1(&'a i32),
    R2(&'b i32),
}

can this syntax already be implemented using macros?

Yes, enum syntax can be implemented using macros, but important part is how you would match on this type of enum using types directly. This is what you wouldn't be able to achieve without using macros around match, but this approach misses the idea of things being easy to use.

@IoaNNUwU
Copy link
Author

IoaNNUwU commented May 4, 2024

Also I've read through previous RFCs with similar things suggested and I see the problem with type system ergonomics in match. I don't think we can cover every usage because how complex type system is, but I wonder if it is possible to add an exception with this one and just say variants in this enum have to be owned types with no lifetimes attached. If this isn't possible, I think all answers were already given in previous RFCs and this issue can be closed.

@kennytm
Copy link
Member

kennytm commented May 4, 2024

For example currently you cannot use named lifetimes in patterns so types &'a i32 and &'b i32 are indistinguishable.

match ref1 {
    Refs::R1(&'a n) => {},
    Refs::R2(&'b n) => {},
}

No it's just because &'a x is not valid pattern syntax. And whether &'a i32 and &'b i32 are distinguishable is irrelevant for a normal enum since they belong to different variants (R1 and R2).

@IoaNNUwU IoaNNUwU closed this as not planned Won't fix, can't repro, duplicate, stale May 5, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants