Live data from Hacker News

Using enums to represent state in Rust

corrode.dev

71–80 of 89 posts

Re: Using enums to represent state in Rust

#71
post #66

Earlier quoted context omitted.

Ah ok I think we're mixing up terms here - in the context of systems programming languages, a "tagged" union refers to an integer in front of a bag of bytes that holds the data of the "un tagged" union. Rust has both tagged (enums) and untagged (unions) union types. What you're asking about is a discriminated vs non-discriminated union, and indeed, that's exactly what I'm talking about. A | B |C is not the same type…

> because Either > cannot type check as Either Why would you want the former to type check as the latter? Where do you see the complexity?

Consider this code:

    fn foo () -> A | B | C {
        if condition {
            bar();
        } else {
            baz();
        }
    }

    fn bar() -> A | B {
        ...
    }

    fn baz() -> B | C {
        ...
    }

vs

    fn foo () -> Either {
        if condition {
            match bar() {
                Either::Left(a) => Either::Left(a),
                Either::Right(b) => Either::Right(Either::Left(b)),
            }
        } else {
            match baz() {
                Either::Left(b) => Either::Right(Either::Left(b),
                Either::Right(c) => Either::Right(Either::Right(b)),
            }
        }
    }

    fn bar() -> Either {
        ...
    }

    fn baz() -> Either {
        ...
    }
The latter code composes poorly and requires an extra branch at runtime. It is fundamentally more complex to dispatch on nested discriminated unions instead of flat non-discriminated unions both for the programmer to write, read, and for the runtime to execute.

The compiler can also optimize the representation of the anonymous enum based on the context in which its created, whereas its more difficult to do that in the discriminated case.

This isn't a controversial opinion, there are mountains of Typescript written in this style.

Re: Using enums to represent state in Rust

#72
post #54

Earlier quoted context omitted.

It sounds like Rust is in dire need of something similar to C++ decltype.

This feature wouldn't make a lot of sense in most cases in Rust. Rust has full type interference inside functions, so if we're inside the function body we can allow the type to be inferred, partially or entirely. For example Vec says this is a Vec of something but we're not specifying what it's a Vec of. In the function signature, Rust deliberately doesn't have inference, you must write down the types and decltype wo…

There’s one thing I can think of that looks like an exception to the lack of interference at function boundaries: you can return impl SomeTrait rather than worrying about the exact thing you’ll return. It’s useful for iterator adapters in particular, where the types depend on the functions you call and in which order, and thus aren’t stable under small modifications to the source code.

(I wouldn’t count impl trait in function parameters since that acts more like a generic type.)

Re: Using enums to represent state in Rust

#73
post #70

Earlier quoted context omitted.

> because Either > cannot type check as Either Why would you want the former to type check as the latter? Where do you see the complexity?

Because your code might not need to care about the position you insert your A or B (left/right for Either), you also might not care whether it's an (encoding as) Either or SomeoneElsesEither and you also don't want to have to deal with flattening nested Either's as in the example. These types are also called "set-theoretic" types as A|B means exactly the set of all values that can be typed as A or typed as B - note t…

> Because your code might not need to care about the position you insert your A or B

This is understandable. But what does it have to do with "collapsing" `a | a` into `a`? Throughout your post I think you're talking about plain untagged union types but that's something the guy I've been replying to already ruled out. Position problem can be handled beautifully by variants based on row polymorphism, such as in OCaml or PureScript. There you can access the fields not by their position but by a key, like keys in objects in JS, meaning that they don't have to be ordered at all. It's like an inverse of a struct: in a struct all fields/keys are guaranteed to exist, but in a variant only one of them exists. Due to row polymorphism they can also be extensible. You can even "handle" a particular field/key and remove it from the type but keep all the other ones and delay handling them.

> you also might not care whether it's an (encoding as) Either or SomeoneElsesEither

This is a theoretical issue but in practice I don't think I've ever seen anyone using some non-standard Either-like datatype in languages I've dealt with. Where Either needs to be used people just use Either.

> and you also don't want to have to deal with flattening nested Either's as in the example

What would "flattening" mean here? Fundamentally there are only 2 operations you can do on a generic sum type like this: either inject a value (construct the type) or try to get the value at a certain position. You might also think pattern matching will get tedious, but that's not the case either, you can just have a function `actOnAorBorC` and call it with `actOnA`, `actOnB` and `actOnC` and do the pattern matching inside these functions.

Re: Using enums to represent state in Rust

#74
post #71

Earlier quoted context omitted.

> because Either > cannot type check as Either Why would you want the former to type check as the latter? Where do you see the complexity?

Consider this code: fn foo () -> A | B | C { if condition { bar(); } else { baz(); } } fn bar() -> A | B { ... } fn baz() -> B | C { ... } vs fn foo () -> Either { if condition { match bar() { Either::Left(a) => Either::Left(a), Either::Right(b) => Either::Right(Either::Left(b)), } } else { match baz() { Either::Left(b) => Either::Right(Either::Left(b), Either::Right(c) => Either::Right(Either::Right(b)), } } } fn ba…

So basically the idea here is that you want to have TS-style untagged unions, but instead they're also tagged, but still unify and compose the way they do in TS? Then why couldn't you just do `{ tag: A, data: … } | { tag: B, … } | { tag: C, … }`? Wouldn't it solve your problem?

We didn't start with composability as a requirement but you're right in that if it's a goal then nesting Either's is a rather poor solution. A better fit would be variants based on row polymorphism as I described in the reply to the other poster.

It wouldn't be a 1:1 mapping to your first example though, if your union is ultimately closed (as in your first example) then you'd still need to have one extra no-op function call to unify the types. Not a big deal but row-polymorphic variants lose here. On the other hand, IMO the possibility of having them open as well is the killer feature.

Ultimately though, I don't like this style of type unification as the one happening in your first example. Shaped by the languages I'm working with, I simply don't end up in situations where I'd need something like this. I just approach the problems differently. But this is more of a subjective territory here.

Re: Using enums to represent state in Rust

#75
post #70

Earlier quoted context omitted.

Because your code might not need to care about the position you insert your A or B (left/right for Either), you also might not care whether it's an (encoding as) Either or SomeoneElsesEither and you also don't want to have to deal with flattening nested Either's as in the example. These types are also called "set-theoretic" types as A|B means exactly the set of all values that can be typed as A or typed as B - note t…

> Because your code might not need to care about the position you insert your A or B This is understandable. But what does it have to do with "collapsing" `a | a` into `a`? Throughout your post I think you're talking about plain untagged union types but that's something the guy I've been replying to already ruled out. Position problem can be handled beautifully by variants based on row polymorphism, such as in OCaml…

> Position problem can be handled beautifully by variants based on row polymorphism, such as in OCaml or PureScript. There you can access the fields not by their position but by a key, like keys in objects in JS, meaning that they don't have to be ordered at all. It's like an inverse of a struct: in a struct all fields/keys are guaranteed to exist, but in a variant only one of them exists. Due to row polymorphism they can also be extensible. You can even "handle" a particular field/key and remove it from the type but keep all the other ones and delay handling them.

Exactly. OCaml's polymorpic variants implement a subset of set theoretic types for specifically defined types - see also this ICFP'16 paper https://dl.acm.org/doi/abs/10.1145/2951913.2951928

For languages with more first-class/principles set-theoretic types see the Ceylon type system (sadly dead and archived at Eclipse ceylon-lang.org) or TypeScript (though they obviously also have to deal with JS which makes everything more messy than necessary).

With "Flattening" I mean applying the usual laws of set theory for simplified types: Either,A>> is doesn't express our intent for a function return or parameter type if we don't care about the position of A, just whether it is an A, the same with Either>>/etc, so we'd want all nested variations normalized to Either. But we also don't care about the difference between Either and Either - normalizing this is already not easy without metaprogramming/type reflection. At this point it ceases to have any significant relationship to the original Either type. If we'd use it still to signify A|B and would actively need to call normalizing functions to keep our types clean and simple in this way, that adds non-semantic (regarding the intent of our code) noise to our code or we need to hide the complexity by using more abstract tools like e.g. monad transformers. If instead the language already provided these types, this complexity caused by embedding set theory inside the language doesn't leak into our code and our intent can be expressed more clearly in types without "bookkeeping" artifacts. This is only exacerbated when going to higher arities of sets/Either.

Re: Using enums to represent state in Rust

#76
post #12

Rust ADTs and pattern matching are so much better than other mainstream languages I find that once my code compiles it actually is almost always correct. The next step is to encode your transition logic in the From impls between the enum structs and you've got yourself a first-rate state machine.

I wish it had nested object destructions like C# has. The best part of it all is that it can return expressions.

Re: Using enums to represent state in Rust

#77
post #75

Earlier quoted context omitted.

> Because your code might not need to care about the position you insert your A or B This is understandable. But what does it have to do with "collapsing" `a | a` into `a`? Throughout your post I think you're talking about plain untagged union types but that's something the guy I've been replying to already ruled out. Position problem can be handled beautifully by variants based on row polymorphism, such as in OCaml…

> Position problem can be handled beautifully by variants based on row polymorphism, such as in OCaml or PureScript. There you can access the fields not by their position but by a key, like keys in objects in JS, meaning that they don't have to be ordered at all. It's like an inverse of a struct: in a struct all fields/keys are guaranteed to exist, but in a variant only one of them exists. Due to row polymorphism the…

> Either,A>> doesn't express our intent for a function return or parameter type if we don't care about the position of A, just whether it is an A

> so we'd want all nested variations normalized to Either.

Sorry, perhaps my thinking is shaped by nominal type systems rather than structural, but if the only thing we care about is whether the type is A, then how do we end up having Either, A>> in the first place? Thinking about this in terms of a nominal type system, the specific type you present here has to have some specific meaning associated with, specifically, this type, otherwise we would have chosen some other type. So the key thing here is that if we have Either then it HAS to be distinct from simply A, otherwise we wouldn't have this type in the first place. Us constructing it means we associate it with a specific meaning so it has to be distinct from A. But if we DON'T care, then, I guess, we shouldn't use this type? Use the type we do care about? The same goes for Either and Either.

> or we need to hide the complexity by using more abstract tools like e.g. monad transformers

This is interesting, how do monad transformers relate to this problem?

Re: Using enums to represent state in Rust

#78

This is good, but could go further if you're pursuing type-system leverage. For example, why does a deleted user have an `activate` associated function in the first place? It will error out! That's safe in Rust and perfectly fine in terms of control flow: it cannot be forgotten, and cannot easily be handled incorrectly (unlike, say, a bool, where a single exclamation mark can mean a nasty bug). But it's not ideal. I'…

It's less good when the state machine traversal is only known at run time. Necessarily you can only capture the errors at run time, so you get less benefit. You can wrap the states in an enum, but then the enum needs to implement the interface of every state. In that pattern though, you do get the benefit that the enum level dispatcher has to properly obey the types' interfaces, so you get lots of confidence that the…

Sounds like polymorphism with more steps.

An interface with multiple implementations.

Re: Using enums to represent state in Rust

#79
post #50
post #24

Earlier quoted context omitted.

I sometimes wish Rust had combined structs and enums into a single concept - same an enum. Structs would have been unnecessary. The compiler can simply avoid the tag or the union when a pure struct or a pure (c-type) enum is required, respectively.

> The compiler can simply avoid the tag or the union when a pure struct or a pure (c-type) enum is required, respectively. I had to try: pub enum Foo { Foo { a: i32 }, } impl Foo { pub fn new() -> Self { Foo::Foo { a: 42 } } pub fn get_a(Foo::Foo{a}: &Self) -> &i32 { a } } At opt level above zero (-C opt-level=1) the tag is elided: example::Foo::new: mov eax, 42 ret example::Foo::get_a: mov rax, rdi ret https://godbo…

That really does make structs superfluous, doesn't it? The only additional thing needed is a syntax sugar, where the variant name can be avoided if there is only one variant.
Post reply on HN