Readit News logoReadit News
hqudsi · 2 years ago
I sometimes do this but idk if I would consider it 'elegant'

The other 'gotcha' is that in switch statements the compiler can't tell whether you enumerated on all your cases as there is no true enum type so it's not uncommon to have a catch all default case that either returns an error or panics and hope you can catch it during tests if you missed a case.

I just wish go had proper sum types.

orbz · 2 years ago
This is an analyzer that will catch this: https://github.com/nishanths/exhaustive

I believe it's in golangci-lint.

RetpolineDrama · 2 years ago
>I just wish go had proper sum types.

It's by far my favorite feature of Swift.

Enums + Payloads + switches are incredibly simple yet so effective. You can make a simple state object, isolate it with an actor, then stream it anywhere with Combine (or now Observability). You'll get full compiler safety too, forcing any new state to be handled everywhere.

You can even stack that with _generic_ payloads that conform to protocols, and if you're feel brave you can make those payloads equable completions for _typed returns_.

for example (I hate formatting on here, but I'll give it a shot)

// A user state enum that conforms to loggable.

// The state is generic, any object T that conforms to "Partner" protocol can be a partner

enum UserState<T: Partner>: Loggable {

   // Your states
   case loggedOut(error: String?)
   case loggingIn(email: Email)   // Email type is a string that's been validated
   case loggedIn(authToken: String, user: User, partner: T)
   case loggingOut(exampleBlock: (String) -> Bool) // You can embed a callback here
   

   // Utility Functions

   /// Is the user logged in?
   func isLoggedIn() -> Bool {
      switch self { 
         case .loggedOut, .loggingIn: return false
         case .loggedIn: return true
      }
   } 

   /// Loggable conformance
   func logText() -> String {
    // switch that converts state into something that's safe for your logging service
   }
}

In the above, any subscriber that gets a UserState object can switch on it, and for example if you're logged in you 100% get the auth token, user, etc. It's impossible to be logged in without those in-context.

It might look something like this in your UI:

/// Called by a subscriber that's hooked in to the state stream

func onUserStateChanged(newState: UserState) {

   switch newState {

      case let .loggedOut:
      // Impossible? Error UI

      case let .loggingIn, .loggingOut:
      // loading UI

      case let .loggedIn(authToken: _, user: user, parner: partner):
      // set some text "hello \(user.userName)"
      // display an image: \(MyRemoteLoader.url(url: partner.primaryLogoURL))
      // Button to website: Button(url: partner.homePageURL)
   }
}

add a new state later? The compiler will error in the 500 places in your codebase you didn't handle the new case with one build command.

schrodingerscow · 2 years ago
Love this. Not only is it my favorite feature of swift, but I often say swift enums are my favorite feature of any programming language. They are just so elegant and powerful
jchw · 2 years ago
I dunno if I'd consider having a dummy method on an interface as "elegant", but it does work. A trade-off of keeping the language very simple, for sure.

What I really wish Go had was sum types, Rust style. That'd cover enumerations and more.

jerf · 2 years ago
If you are satisfied with a dummy method on an interface, you can continue by adding more types to that interface. Color is not a great example, so:

    type Vehicle interface {
         isVehicle()
    }

    type Car struct {}
    func (c Car) isVehicle() {}

    type Van struct {}
    func (v Van) isVehicle() {}

    func VehicleType(vehicle Vehicle) {
        switch v := vehicle.(type) {
        case Car:
            fmt.Println("car")
        case Van:
            fmt.Println("van")
        default:
            fmt.Println("unknown vehicle")
    }
This covers most, but not all, of the bases, in that you don't get exhaustiveness checking at compile time, unless you adjoin a linter to your compile process: https://github.com/BurntSushi/go-sumtype

jchw · 2 years ago
Yeah, I've done that before. It's not really elegant in my opinion, but OTOH, it's basically the best you can do. Oh well.
jjice · 2 years ago
I go back and forth on this. On one hand, I _love_ Rust/ML style sum types. They're such a fantastic feature. On the other hand, I wonder how much use they'd get in a language like Go. If it's introduced, would it radically change the way some problems get solved? Is it that different than an traditional enum (which Go also skirts around)?

Pattern matching and exhaustive checks are massive benefits of them though. I guess I'm just oddly conflicted here.

I do think that long term, sum types are going to become more prevalent. I'm excited to see it, I'm just interested in how that adoption occurs in existing languages without them.

jerf · 2 years ago
I think the answer to your "back and forth" is probably laid out in: https://jerf.org/iri/post/2960/

Sum types are useful, even very useful in the right place, but I do think there's a lot of people who use them a couple of times, probably in one of those "right places" and then mistakenly label them in their mind as "better". Just, universally, Platonically "better". They aren't in fact "better"; they're a tool. Sometimes they're the right tool for the job, but much, much more often, they're just a tool that works, and so do several other tools. People who get too excited about sum types need to be sure they square their understanding of how useful they are with the fact that the vast majority of programs are written without them.

Zach_the_Lizard · 2 years ago
Sum types with pattern matching would likely take the place of (foo, error) in a lot of codebases.

This is especially true in cases where a function returns a collection of results where each result is independent from other results and a result can succeed or fail.

For instance, a batch report generator that is called every $INTERVAL might return a ([]Report metadata, error) today, but each report could have succeeded or failed without impacting the others.

The output is emailed to $SOMEONE to let them know the reports ran and information about each.

In today's world, the "error" could be a special error type that capture failures on a per report basis. The ReportMetadata type could also have an Error field, but one is not forced to check either.

Sum types could force checking for error on a per report basis, increasing the odds something is done with the error.

jchw · 2 years ago
If I had sum types in Go, I'd use them for state machines and enumerations mostly. I can't see a huge downside; I'm sure some software has little use for it, but it's useful enough for data modelling that protobuf has the sort-of similar oneof primitive. Hmm. On that note, maybe it would be better than nothing to do some kind of code generation here...
fsdjkflsjfsoij · 2 years ago
I hope Go will eventually get sum types but I hope they're better than Rust's where each variant is its own type.
LatticeAnimal · 2 years ago
How is this mess better than Rust's enums? A Rust enum + match statement results in really easy to read and clean code.
sapiogram · 2 years ago
> Rust's where each variant is its own type.

Is this... right? You can't use enum variants as types in function signatures, variable declarations, etc.

kiitos · 2 years ago
Unfortunately not.

    func main() {
     c := color.Red
     cptr := (*string)(&c)
     *cptr = "orange"
     PrintColor(c) // successfully compiles, and prints "orange"
    }

tedunangst · 2 years ago
At some point I think it's worth asking who are we trying to stop and why.
kiitos · 2 years ago
Indeed. Go types are like simple locks: they keep honest users honest, but they don't prevent dishonest users from breaking your assumptions. The point is just that "compile-time type safety for enumerations" isn't actually true, or even possible. You always have to do some amount of runtime validation of received values.
revoly · 2 years ago
I don't think the solution is trying to cover this. If a user does type conversion then usually they know what they are doing. It's like going out of the way. The solution is to just hide a type behind an interface to be explicit about it and to avoid the error that would have gone unnoticed otherwise.
kiitos · 2 years ago
The article is titled "Compile-time safety for enumerations in Go" but my example demonstrates that this is not the case.
throwaway894345 · 2 years ago
The type conversion is a red herring. He's just changing the value of a variable by referencing and dereferencing a pointer. In other words, he's not using a pointer conversion to change the value of `color.Red`, he's creating a new variable `c` and assigning `color.Red` to it, and then changing the value of the variable through the pointer and type conversion. But he doesn't even need to do the pointer/type conversion stuff, he could just do `c = Color("orange")` and call it a day.
hamdouni · 2 years ago
It's the game of the mouse and the cat :-D We can change the package color like this :

  package color

  type Color interface {
    void()
  }

  type color struct {
    v string
  }

  func (c color) void() {}
  func (c color) String() string {
    return c.v
  }

  var (
    Red   color = color{"red"}
    Green color = color{"green"}
    Blue  color = color{"blue"}
  )

kiitos · 2 years ago

    func fn(c color.Color) {
     switch c {
     case color.Red, color.Green, color.Blue:
      log.Printf("c=%#+v -- OK", c)
     default:
      log.Printf("c=%#+v -- should not be possible", c)
     }
    }
    
    func main() {
     var c color.Color
     fn(c)
    }    
Output:

    c=<nil> -- should not be possible
:shrug:

throwaway894345 · 2 years ago
You're not changing the underlying value of `color.Red`, you're changing the variable (in other words, `PrintColor(color.Red)` will still print `red`). If that's the point you're intending to make, then couldn't you make it more easily with this?:

    c := color.Red
    c = Color("orange")
    PrintColor(c)

masklinn · 2 years ago
> You're not changing the underlying value of `color.Red`

Doesn't matter, the point is they're getting in a value which is not part of the defined set, demonstrating that this emulation is not actually typed-safe.

> If that's the point you're intending to make, then couldn't you make it more easily with this?:

    cannot convert "orange" (untyped string constant) to type Color

avg_dev · 2 years ago
i appreciate this counter-example; thanks. i was hoping there was a way to get real enums in go.
gabereiser · 2 years ago
In this example, *cptr is a color by contract. A Color is a type alias of a string. *cptr is a string. Interfaces match.
kiitos · 2 years ago
Technically not a type alias, just a normal type declaration. But yes, this is my point: you can't assume that an enum type will always be one of a fixed set of values that you've defined. You always have to do at least a little bit of runtime validation of the parameter. It's unavoidable.
SPBS · 2 years ago
This is not elegant. It also has overhead. Stick with the simple `const Red Color = "red"`, you're not gaining a lot by doing all these strange type contortions in Go. Seriously consider what you are protecting against, and whether it's an imagined bogeyman.

"Oh but someone may try to cast arbitrary values to my package type" Okay but who is that going to hurt? You or them? Will they get hit with errors early on in the development lifecycle if they do something silly like this? Are you actually going through all these lengths for nothing?

the_gipsy · 2 years ago
It's stuff that just creeps in. Enum values can come from outside (json, anything non-literal). Now you have to do validation, because the bad values parse and for sure exist at some stage in your program.

It's not a bogeyman.

SPBS · 2 years ago
Yes it is important to have validation for anything coming in from outside (json, the database, etc). This is done by creating a map with the valid enum values and checking at runtime. This compile time solution wouldn't have worked for validating enums sent in json anyway, it's purely a defense mechanism against library users trying to cast arbitrary values to a package type.
Groxx · 2 years ago
Accidental zero value enums are a constant plague as far as I've seen. Almost any defense is worth it.
ollien · 2 years ago
What overhead is there at runtime?
Groxx · 2 years ago
Interfaces are both allocations and function-call indirection, and I think they also always move their data to the heap.

Which is almost always trivial except when it isn't.

kgeist · 2 years ago
Casting a value to an interface forces it to be allocated on the heap.
the_gipsy · 2 years ago
You need the JSON de/encode implementation with checks. At that point you'll want code generation, and something that was never elegant is turning awful.
davidkunz · 2 years ago
> Go’s type system allows preventing both issues in a rather elegant way.

Proper enums would be elegant, not this.

masklinn · 2 years ago
Some way to define a closed set of values anyway. It doesn't even need to be classic-style sum types, for instance as support for generics Go introduced support for union types (I don't think they have a name?) e.g.

    type Foo interface { A | B | C }
and the interface type is the union of those type-sets. Such interfaces can not currently be used outside of type constraints, but if that is relaxed, and type switches are updated to support and enforce exhaustive matching (and understand such sealed / nominative interfaces), you've got all the bits you need.

You'd need to newtype variants to add payloads of similar underlying type e.g. `int | int`, but that's not a huge imposition, and the variants being types themselves is often convenient so it's a 50:50 tradeoff compared to classic sum type (where constructors disambiguate all variants but are not themselves types).

foldr · 2 years ago
Agreed. A slight wart is that default initialization would require either boxing or a somewhat arbitrary decision about which variant should be the default. So if you have e.g.

    var foo interface{ A | B }
then foo either has to be boxed (so the default value is a nil interface) or unboxed and arbitrarily initialized as an A or a B.

atombender · 2 years ago
The main downside to this approach is that you'll still have to deal with the zero value, which for interfaces is going to be nil.
Groxx · 2 years ago
While I agree nils are a problem with this: as long as they have access to the type at all, they can create zero values. E.g.

  var c color.Color
That's a valid color whether it's a nil interface value or an empty typed string.

You can return a private type to prevent this, but that also means they can't refer to it as an argument or return value anywhere outside the implementing package, which is a rather severe limit in many cases.

Sometimes* I really miss constructors.

*: all the time

masklinn · 2 years ago
No matter how Go solves the issue (assuming it ever does), that will be part of it: unless it goes through a revolution and strips out and forgets about ubiquitous default values (which I don't think it will, C# has barely just dipped its toes into that pool) any sort of sum-type-like construct will need a default value. And I'm not sure `nil` (a clearly corrupted / missing value) is any worse than picking an actual valid value the way non-pointer types do.
andreygrehov · 2 years ago
Another approach could be:

  package color

  type Color struct {
    val string
  }

  func (c Color) String() string {
    return c.val
  }

  var (
    Red   = Color{val: "red"}
    Green = Color{val: "green"}
    Blue  = Color{val: "blue"}
  )
Since `val` is not exported, external packages cannot create arbitrary `Color`.

curvilinear_m · 2 years ago
Ins't it still possible to construct the zero value for Color ? Like how bytes.Buffer does it https://pkg.go.dev/bytes#Buffer : the implementation is not exported but you the usage is to construct the zero value and use it. So unless Color{} is a valid enum value, this solution would not work.