golang / go

The Go programming language
https://go.dev
BSD 3-Clause "New" or "Revised" License
122.03k stars 17.44k forks source link

proposal: spec: sum types based on general interfaces #57644

Open ianlancetaylor opened 1 year ago

ianlancetaylor commented 1 year ago

This is a speculative issue based on the way that type parameter constraints are implemented. This is a discussion of a possible future language change, not one that will be adopted in the near future. This is a version of #41716 updated for the final implementation of generics in Go.

We currently permit type parameter constraints to embed a union of types (see https://go.dev/ref/spec#Interface_types). We propose that we permit an ordinary interface type to embed a union of terms, where each term is itself a type. (This proposal does not permit the underlying type syntax ~T to be used in an ordinary interface type, though of course that syntax is still valid for a type parameter constraint.)

That's really the entire proposal.

Embedding a union in an interface affects the interface's type set. As always, a variable of interface type may store a value of any type that is in its type set, or, equivalently, a value of any type in its type set implements the interface type. Inversely, a variable of interface type may not store a value of any type that is not in its type set. Embedding a union means that the interface is something akin to a sum type that permits values of any type listed in the union.

For example:

type MyInt int
type MyOtherInt int
type MyFloat float64
type I1 interface {
    MyInt | MyFloat
}
type I2 interface {
    int | float64
}

The types MyInt and MyFloat implement I1. The type MyOtherInt does not implement I1. None of MyInt, MyFloat, or MyOtherInt implement I2.

In all other ways an interface type with an embedded union would act exactly like an interface type. There would be no support for using operators with values of the interface type, even though that is permitted for type parameters when using such a type as a type parameter constraint. This is because in a generic function we know that two values of some type parameter are the same type, and may therefore be used with a binary operator such as +. With two values of some interface type, all we know is that both types appear in the type set, but they need not be the same type, and so + may not be well defined. (One could imagine a further extension in which + is permitted but panics if the values are not the same type, but there is no obvious reason why that would be useful in practice.)

In particular, the zero value of an interface type with an embedded union would be nil, just as for any interface type. So this is a form of sum type in which there is always another possible option, namely nil. Sum types in most languages do not work this way, and this may be a reason to not add this functionality to Go.

As an implementation note, we could in some cases use a different implementation for interfaces with an embedded union type. We could use a small code, typically a single byte, to indicate the type stored in the interface, with a zero indicating nil. We could store the values directly, rather than boxed. For example, I1 above could be stored as the equivalent of struct { code byte; value [8]byte } with the value field holding either an int or a float64 depending on the value of code. The advantage of this would be reducing memory allocations. It would only be possible when all the values stored do not include any pointers, or at least when all the pointers are in the same location relative to the start of the value. None of this would affect anything at the language level, though it might have some consequences for the reflect package.

As I said above, this is a speculative issue, opened here because it is an obvious extension of the generics implementation. In discussion here, please focus on the benefits and costs of this specific proposal. Discussion of sum types in general, or different proposals for sum types, should remain on #19412 or newer variants such as #54685. Thanks.

merykitty commented 1 year ago

@merykitty No, in your example, Addable itself should not be able to instantiate Add. Addable does not implement itself (only int and float32 do).

@Merovius So what you mean is that an Addable does not satisfy itself during generics parameter resolution but it will do during runtime assignments? That seems a little confusing to me.

Merovius commented 1 year ago

@merykitty Maybe. It's already a situation we will be in with Go 1.20 and comparable. So there is precedent for these two things to be different. I also don't think it's an entirely natural idea for these to mean different things in different contexts - that is, an Addable variable is "an opaque box that can hold any of these types" while an Addable constraint is "the type argument must be any of these types".

So, yes, I think there is a certain amount of possible confusion here. But I'm not sure how confusing it'll be, how often it will be a problem and I'm not sure it's avoidable. Surprisingly, there are things which are confusing if you think about them, but if you don't, you just never notice. For example, I doubt most Go programmers could really explain why they can't use a bytes.Buffer as an io.Reader, even though they can call r.Read on it - but in practice, they manage to use it just fine.

atdiar commented 1 year ago

@merykitty you can see it as Addable not satisfying itself in both cases (the constraint being that something should be either int and float32)

It should implement itself however (the Addable type implements the same constraint (i.e. enforce the same contract as itself)) . Note that interface{int} also implements Addable as it merely enforces the contract more strictly: not only arguments are int or string, but we know for a fact that they have to be int.

Modulo nil, which I'm optimistic (or at least hopeful) can be solved.

This is similar to a ReaderWriter interface implementing the Reader interface. (subtyping)

Merovius commented 1 year ago

@merykitty FWIW as an analogy: It also doesn't seem like many people are confused that an io.Reader variable can't contain an io.Reader - that is, the dynamic type of an interface is never an interface itself. It's essentially the same situation, it's the same confusion, yet in actual practice no one really wonders why that is.

AndrewHarrisSPU commented 1 year ago

If we had:

type Vehicle interface {
    Car | Bicycle
}

type Mover interface {
    Move()
}

would we say that Vehicle satisfies Mover if each element of Vehicle (Car.Move, and Bicycle.Move) do as well?

leighmcculloch commented 1 year ago

@AndrewHarrisSPU As I interpret the proposal, you'd need to have a Move() function on Vehicle so that Vehicle types implement Mover.

type Vehicle interface {
    Car | Bicycle
    Move()
}

type Mover interface {
    Move()
}

Or you could do:

type Vehicle interface {
    Car | Bicycle
    Mover
}

type Mover interface {
    Move()
}
DeedleFake commented 1 year ago

@AndrewHarrisSPU:

If we had:

type Vehicle interface {
    Car | Bicycle
}

type Mover interface {
    Move()
}

would we say that Vehicle satisfies Mover if each element of Vehicle (Car.Move, and Bicycle.Move) do as well?

Worth noting that that does not work with constraints currently, either: https://go.dev/play/p/TLkZkYzOcdO

I think it makes sense for it not to work. It would be quite confusing and annoying to have to go searching through every type listed and figuring out the intersection of their available methods to see what you could do with it. Instead, just rely on the principle of defining interfaces where they're used and add the expected methods to the interface manually, which should then work.

AndrewHarrisSPU commented 1 year ago

@DeedleFake

Worth noting that that does not work with constraints currently, either: https://go.dev/play/p/TLkZkYzOcdO

I think it makes sense for it not to work. It would be quite confusing and annoying to have to go searching through every type listed and figuring out the intersection of their available methods to see what you could do with it.

If we can define a truly disjoint, finite, non-nil-able (or at least nil is an explicit element) type set, we can't include interfaces, but do we need interfaces to reason about the behaviors that are defined on that type set? I'm thinking (maybe naively?) that a compiler can tractably compute various sets-of-method-sets from the type set here. In practice I think a compiler could emit some precise and useful information ("error: Vehicle union doesn't implement Brake(): jetpack doesn't implement Brake()").

Going off-track a bit, I think there's also cases where defining a method on an element in a union itself could be interesting - I could call SetAlpha() on the union of rgb and rgba and still maintain a valid union, but not on an rbg value in isolation. In this case the union could satisfy SetAlpha(), but not if we required SetAlpha() to be defined on rgb.

Merovius commented 1 year ago

I believe the need to explicitly list methods in interfaces containing union elements is an implementation restriction by the current Go compiler and should be lifted sooner or later. I don't see a good reason why it can't (though sometimes these things are surprisingly subtle - there are other implementation restrictions which I don't think can be lifted, or am at least skeptical about). Though I don't understand what "nil-able" has to do with it, you can call methods on nil values just fine.

Also, I agree with the criticism that it's a downside not to be able to define methods on union types, if they are defined like this proposal. Though a lot of the boiler plate can probably be reduced by struct embedding. As for the SetAlpha example, an alternative would be to have the method be RGBA() rgba, which could be implemented on both types and the usage would then be x = x.RGBA(), instead of x.SetAlpha(), which doesn't seem that bad.

AndrewHarrisSPU commented 1 year ago

Though I don't understand what "nil-able" has to do with it, you can call methods on nil values just fine.

With a closed, finite type set, do we have to recycle nil as a catch-all? I think we could disallow declaring an instance of a sum type without declaring a variant, and ask implementors to explicitly provide empty/zero/undef variants - at least, I really enjoy this about sum types when I've used them in other languages. If a sum type exhibits a field that is unsafely nil, maybe that could be regarded as programming error that justifies a resulting panic just like it would otherwise.

I'm not sure it'd be insurmountable to do things more like the proposal suggests, and recapitulate the nuances of nils and interfaces, but it makes me nervous ... it looks manageable in small type switches but I think it could get nastier in practice - hard to reason about disjointness.

apparentlymart commented 1 year ago

Currently the language seems to rely on every type having some meaning for the value that is represented as all zero bytes in memory. For example:

type Example interface {
    int | string
}

m := make(map[string]Example)
v, ok := m["foo"]

Under the current proposal I would expect v to be nil because that is the zero value of Example. If a nil Example were forbidden then it isn't clear what v ought to be here.

Personally, I feel okay with accepting nil interface values as an established part of the "texture" of Go and having these "sealed" interface types inherit that assumption, rather than introducing the one situation where there isn't a zero value and dealing with the effects on all other parts of the language that gave been defined on the assumption of zero values, although I do agree that it'll mean that patterns from other languages with different type systems won't translate over exactly.

Each time I revisit this I find myself thinking that this feature perhaps deserves a more specific name than "sum types" to help make it clearer that this is just an application of the theoretical idea of sum types to some specific situations, and not something that is intended to cover all possible use-cases for sum types. I still quite like "sealed interfaces" because it seems more clearly a special kind of interface and so inherits most of what we're already accustomed to with interfaces (including nils and method sets) and focuses only on constraining the full set of implementers at the declaration site.

apparentlymart commented 1 year ago

Although I don't think it's a deal breaker, I think it is notable that declaring all of the implementers inside the interface block means that the package which defines the interface must import any packages which export types that will be included in the set.

This means that the package that exports a type set member would be unable to import the package containing the interface and so could not name the interface type to use it in its own code without creating an import cycle.

Most of the use-cases we discussed above aren't impacted by that problem so I think this proposal is still useful despite it, but I do think it's interesting to think about given that it seems to invert the usual way that interface implementation works, where it's the package that defines the implementer that is responsible for (implicitly) adding it to the type set of the interface.

DeedleFake commented 1 year ago

Although I don't think it's a deal breaker, I think it is notable that declaring all of the implementers inside the interface block means that the package which defines the interface must import any packages which export types that will be included in the set.

That's actually one of the points of the proposal. The idea is to create something analogous to C's unions or Rust's enums. For example, consider the case of scanning a stream of tokens. It makes sense to have predefined types for the various token types, such as

type Token interface {
  Number | String | Operator
}

type Number struct { /* ... */ }
type String struct { /* ... */ }
type Operator struct { /* ... */ }

// Not a great API, but it demonstrates the idea.
func Parse(r io.Reader) ([]Token, error) {
  // ...
}

There are a surprising number of situations where a value can be limited to one of a handful of possibilities that are all known in advance. This proposal is designed to improve the ergonomics around those situations. The current way that something like the above is usually handled is to define the token type as type Token any, but this is error prone because the types are less discoverable and it loses potential features that are only possible if the compiler is told what all the possibilities are, such as the potential ability to remove boxing mentioned in the proposal itself, linter enforcement of exhaustive type switches, and so on.

jimmyfrasche commented 1 year ago

@Merovius

I believe the need to explicitly list methods in interfaces containing union elements is an implementation restriction by the current Go compiler and should be lifted sooner or later. I don't see a good reason why it can't (though sometimes these things are surprisingly subtle - there are other implementation restrictions which I don't think can be lifted, or am at least skeptical about).

Regardless of whether it can be lifted, I think it should not be.

If you have A | B | C and those types happen to all have an M method, you can never add a type D without a method M to the union—even if M is irrelevant to the purpose and use of the union—because that would remove M from the union's method set.

To be more concrete, I imagine this would happen quite (most?) often when M = String() string.

gophun commented 1 year ago

I think whether switch is exhaustive is an entirely independent proposal to sum types. It can be proposed independent of any type changes, and it doesn't need to be attached to this proposal.

It can't be added later, because it would break by then existing programs. A decision would have to be made together with this proposal.

Merovius commented 1 year ago

@jimmyfrasche You already can not add types to an exported union, without breaking compatibility, regardless of what methods the types involved have. I don't think allowing to call methods would change anything.

Merovius commented 1 year ago

FWIW "adding members to a union" is similar in effect on their type set to "removing a method from an interface" and "removing a member from a union" is similar to "adding a method to an interface". So, unless you are the only user of a union, you really can't do anything about it.

In fact, that's kind of why people want unions. They want a closed set of types. If you could change that set, it would no longer be closed.

gophun commented 1 year ago

In fact, that's kind of why people want unions. They want a closed set of types. If you could change that set, it would no longer be closed.

It's also why I don't like it. Interfaces are a tool to grant freedom, to empower users to provide their own types by implementing them, even if the original author of a function didn't think of them. They mean to open the world for extension, not to close it off.

Merovius commented 1 year ago

Yupp. FWIW in #19412 I brought up the inability to ever modify a union as an argument against their inclusion into a language that is - at least in part - deliberately designed to allow for gradual evolution of APIs a bunch of times. I'm not sure I still totally buy it, as most type system features kind of work that way in one way or another.

But it is something that might be a bit easier with a first-class union/sum type, as you wouldn't run into this aspect of constraints already having variance. For example, if you required a type-switch over a union to always have a default case and made it impossible to assign them to other unions (even if they are subsets of each other), I think you could then add new cases to them backwards compatibly. So, at least in part, it's a point against this specific implementation (overloading union elements for constraints).

jimmyfrasche commented 1 year ago

@Merovius the situation is kind of different in that even if you are doing a v(N+1) you can't add it unless you can add the method (not an option if you want to add a primitive type) or be sure no one relied on the existence of the accidental method (and it's not even obvious that you need to look for this since it sneaks in implicitly). Unduly brittle for little gain when it'd make much more sense to be explicit.

Merovius commented 1 year ago

I don't think I understand. Why wouldn't you be able to do a v(N+1)? And why is "being sure no one relied on the existence of the accidental method" any harder than "being sure no one ever used your interface as a constraint and then called a differently constrained function with it"?

(In any case, this is probably off-topic; this proposal is not about allowing to call methods not explicitly mentioned in an interface with unions)

timothy-king commented 1 year ago

Returning to the go/ast.Node example for a second, an advantage of a union type there would have been to consolidate the definition to one location. type Node interface { *AssignStmt | *BadDecl | ... }. Currently what implements an ast.Node is spread out a bit over a pretty big file (and is kept readable via discipline in how things are written). Only needing to consult one place in the code would have helped me read/use this and similar libraries in the past. This can be addressed by tooling so it is an overwhelming advantage. But overall I think this proposal would help with the readability of some packages.

I suspect we will not be putting 56 cases (# of ast.Node impls in ast) separated by '|' on the same line. So I would anticipate there will be a lot of trailing '|' for larger cases.

type Node interface {
    *AssignStmt |
      // more *Stmts
      *ArrayType |
      // more *Types
      ...
}

Still readable enough IMO, but worth taking into account.

(Not suggesting Node change from its current meaning of range of token.Pos. The token range definition has other existing uses and is a good example of where not to use a closed type set.)

bronger commented 1 year ago

Why is this spread out over a big file and not listed in a comment?

Merovius commented 1 year ago

@timothy-king I think it would rather be written as

type Stmt interface {
    *AssignStmt | *BadStmt | *BlockStmt | … | *TypeSwtichStmt
}

type Expr interface {
    *BadExpr | *BinaryExpr | … | *UnaryExpr
}

type Decl interface {
    *BadDecl | *FuncDecl | … | *GenDecl
}

type Node interface {
    Decl | Expr | … | Stmt
}

There's still relatively long unions there, but it gets more manageable (and it might even be possible to break them up further).

timothy-king commented 1 year ago

@Merovius I suspect Stmt and Decl would not be exported in this case. But your point that these could be broken up into a union of union types is well taken.

AndrewHarrisSPU commented 1 year ago

@apparentlymart

If a nil example were forbidden then it isn't clear what v ought to be here.

Definitely this would require something heavy-handed, I have strong opinions here based only on speculation, but for the sake of speculation - there are places in Go (like having to make maps and chans) where the builtins get special cases, and I’d be interested in going to these lengths to eliminate a ubiquitous ‘nil’. There might be better ideas, disabling the walrus operator for sum types would be brutal and special but seems like one option.

Even if it’s in the machinery that somehow, somewhere a truly invalid instance might panic, I really think the only reasonable response to a ‘nil’ variant of a sum type is panic. Otherwise it’s very tempting to treat such an instance as a zero value of some other included variant, or a predicate to produce a valid value. Then, as a reader of that code, or a writer of code employing an unfamiliar sum type, I simply do not have the ability to immediately observe that ‘nil’ is a properly disjoint case.

ianlancetaylor commented 1 year ago

We've gone down the path of some types not having a zero value several times in the past, and it's never worked. Let's not go down that path again. Let's just assume that in Go types must have a zero value. Thanks.

And since the proposal here is for a particular kind of interface type, and since the zero value for all interface types is nil, that is what this proposal says also. We can certainly discuss a sum type that has a different zero value (there is a lot of discussion over at #19412). But it would be very strange to say that for some interface types the zero value is nil and for some other interface types the zero value is something else. That is a level of complexity that I don't think we are going to add to the language.

Merovius commented 1 year ago

@timothy-king Note that both Stmt and Decl are already existing and exported interface types in the ast package.

atdiar commented 1 year ago

@apparentlymart @AndrewHarrisSPU One idea would be to simply disallow non-nilable unions as channel or map Types (and elsewhere)


type Example interface{ 
    int | string
}
type NilableExample interface{ 
    int | string | nil
}

m:=make(map[string]Example) //compilation error: nil(type?) is not in the type set of Example

m:=make(map[string]NilableExample) // OK

e, ok:= m["something"]
//... 
v, ok:=e.(Example) // regular type assertion to check that it's a legit Example and not nil. 

Of course, the zero value of Example would still be nil. That doesn't change. But such a value would only be created by variable declaration and not assignable where a non-nilable Example is expected.

So would come down to having to be explicit about nil.

Merovius commented 1 year ago

One idea would be to simply disallow non-nilable unions as channel or map Types (and elsewhere)

Or slice types. Or fields. Or interface values.

The language doesn't like types without zero values. That can't really be helped.

atdiar commented 1 year ago

Not necessarily a big issue for slice types either, or fields. One could still define the field case with the explicitly nilable supertype.

Some operations have to be disabled or modified otherwise, for slices of non-zeroable union values, that's true:

(edit: I don't even think that it's important to be able to do that.

If a variable of type T is not zeroable (doesn't mean T doesn't have a zero value btw, just that it cannot be assigned the zero value for that type although var v T is the zero) , one can simply define a slice of {T | nil} which is explicit.

Because the current semantics of slices demand that each indexed slot can be empty or emptied. Essentially, there are a few things that require optionality/ability to assign zero (to denote the lack of value) but that is easily built)

It's merely switching from nilable by default to nilable by construction which should be safer.

I don't think it would be much of a problem a priori. That's workable afaict. It's more an issue of proper value initialization, i.e. assignability.

For interfaces what do you have in mind as an issue?

Edit: I was randomly browsing and came across a similar treatment in Dart https://dart.dev/null-safety/understanding-null-safety So it is possible. I still believe this would be more workable for Go unions since it reuses traditional mechanisms such as type assertions. If subtyping was made more prevalent one day, that could be even further improved but it's not a necessity.

Merovius commented 1 year ago

FWIW the idea of non-nilable interfaces has exactly the sample problems as the periodically happening discussion of non-nilable pointers. It's the same problem. It's not going to happen.

atdiar commented 1 year ago

I'm not sure of what you mean. I'm striclty talking about unions.

Basic interfaces would remain the same. And I don't know what a non nilable pointer is.

Merovius commented 1 year ago

Edit: I was randomly browsing and came across a similar treatment in Dart https://dart.dev/null-safety/understanding-null-safety. So it is possible.

Not to point out the obvious, but Dart is not Go. This is about how other aspects of the design of Go assume that every type has a zero value. Obviously, a language that is not designed under this assumption doesn't have this problem and there are many languages without nil.

atdiar commented 1 year ago

Well I'm well aware obviously... , if you browse through it, there are a few sections that might be of interest such as the fact that they had to deal with initialization of variables. (to implement null safety after the fact!)

So appears that some issues were still shared and they've found a way.

Just saying that it's possible, not that it will be done but before we shoot the idea down for unions, might be interesting to explore it.

apparentlymart commented 1 year ago

I used a map element as an example earlier but note that even a type assertion -- an operation specifically for interfaces, and so would be weird to ban here -- relies on zero value for the non-matching case:

v, ok := interfaceVal.(Type)

Although the type in a type assertion can be a non-interface type, an interface type is also valid in that position and is a common pattern for detecting if the dynamic type in the interface value also implements a more specific interface.

In that situation if the test fails then v is the zero value of the given interface type, which is always nil in today's Go.

This is just one more of many places where Go assumes there is a zero value of every type. I don't think it's feasible to simply ban a particular type from any situation where a zero value is required, because that assumption is all over the language.

atdiar commented 1 year ago

To be accurate, the zero value should still exist and would still be nil.

The issue is rather definite assignment analysis. It is sensitive to branching.

As long as this kind of analysis can be made fast, in a modular fashion, and without false negatives/positives, it should probably be fine.

I think Go might be one language for which it might be possible. (there are others, historically). I don't know if the ssa backend might not be of help here.

That's something to study.

Merovius commented 1 year ago

@atdiar As soon as it is possible to create a zero value, it is literally impossible to guarantee that it's not getting assigned. That is, if I can create a nil T, it is impossible for a compiler to prove that any given T is not nil. Moreover, you've been told multiple times, by multiple people now, that this is infeasible and not going to happen. At this point, saying that there is "something to study" is pretty frustrating. There just is not. Take a "no".

DmitriyMV commented 1 year ago

@atdiar

As long as this kind of analysis can be made fast, in a modular fashion, and without false negatives/positives, it should probably be fine.

Take this function

func AddNElems[T any, S ~[]T](slc S, n int) S {
    return append(slc, make([]T, n)...)
}

Can we prove that this function take only slices of non-nilable elements?

atdiar commented 1 year ago

@DmitriyMV The slice type constructor implicitly requires that T is nilable/zeroable (because a slice can be cleared) . I don't see why such a check would be infeasible.

@Merovius What makes you think that I have to be compelled to agree with you? You don't even understand what I am saying. I'm not even claiming that a variable shouldn't be assigned. I'm saying that a variable shouldn't be assigned the zero value in certain cases. Before claiming that something is impossible, one might want to study. This is not the first time that you make such claims and exhibit an attitude that is slightly disrespectful. I hope it will be the last time.

DmitriyMV commented 1 year ago

@atdiar

The slice type constructor implicitly requires that T is nilable/zeroable (because a slice can be cleared) . I don't see why such a check would be infeasible.

So, you are saying AddNElems where T is any will not work with "non-nilable interfaces" - is that correct?

atdiar commented 1 year ago

Where T is constrained by any? Yes it won't work. (in your example, because T also is declared as a slice type) If you have a non-nilable union type U, U wouldn't be usable in a slice. However interface{U | nil} should be usable as a type argument.

griesemer commented 1 year ago

Just a reminder to keep the discussion here respectful. You know who you are. Thanks.

DmitriyMV commented 1 year ago

@atdiar

So, to be clear, you essentially want a separate class of types (meta types?) similar to existing, but with additional and very specific restrictions to available operations on this "class" of types.

I mean, the whole point of any is that it can satisfy any type (and by extent, being type constraint, works for any type). With what you are proposing, Go type system will be divided not only by two "type hierarchies" but also two "type constraint hierarchies". Essentially, this means we will have two entirely separate type systems which are not interchangeable and have very different low level semantics. Which, in turn. essentially means you will have a two different languages in one - first one assumes that zero state is valid (starting from slices and ending with "reflect") and the second one demands initialization and disallows the set of operations like make([]T, n) and such.

The complexity of implementing this, in the end, equals to creating a new language, so the question becomes - why bother with adding this to Go? What I'm trying to say, is that no matter how we don't like "zero value" in specific situations, it is one of the fundamentals the language is built on. We can adjust sum types to work with it, but we cannot break it or make a parallel mechanism for sum types - this will either be a fundamentally breaking change (the road Dart 2.0 took BTW) or unsound type system. I don't think we want either.

merykitty commented 1 year ago

This is off-topic now, please create another proposal if you have ideas on adding non-zero variables to the language. Thanks.

atdiar commented 1 year ago

Understood. I'll create another prospective issue. To be clear one more time, this is still not "adding non-zero variables" , it is about definite (un)assignment. Zero values are fine, but the runtime.Type of the zero value is not necessarily in the type set which is what I'm trying to address. Doesn't seem that off-topic to me but fair enough. Cheers.

johnwarden commented 1 year ago

I think one of the first things some Go developers would try to do with this feature is implement a generic Option type. Here's a brief exploration of that idea.

My first thought was that it would look like this:

type None struct{}
type Option[T any] interface{None | T}

But the first problem here is that a type parameter can't be used in a type list: the definition above results in a MisplacedTypeParam error.

The second problem is that, since an interface value can be nil, a variable of type Option[T] could either nil, or None, or a value of type T. So it would be a sort of optional optional.

In fact to define an Optional int64 type this would be sufficient:

type OptionalInt64 interface{int64}

This would actually have many of the properties I would be looking for in an option type:

  1. The zero value is, sensibly, nil ("None")
  2. The int64 value would be stored directly (avoiding memory allocations)

I have sometimes used sql.Null* types such as sql.NullInt64 as generic optional types (even, sadly, in code that doesn't otherwise deal with sql) because they have the above advantages. In fact if this proposal was implemented with values stored directly, then OptionalInt64 would be stored as the equivalent of sql.NullInt64.

But OptionalInt64 would add some type safety: you couldn't treat an OptionalInt64 as an int64 without first guaranteeing it was not nil (right?).

A standard generic Option type was discussed in #48702. From what I can see the proposal was rejected because of unanswered questions, and not necessarily because a standard optional type was not desirable.

bronger commented 1 year ago

Difficult question, but I think it may contribute to the “costs” of this proposal: How serious do you estimate the danger that people start using sum types instead of error types as return types? Would this be even feasible?

Merovius commented 1 year ago

@bronger I don't think this proposal is particularly useful for that - and insofar as it is, you could get basically the same effect without it.

One thing people want from a Result[T] type is that it can either be an error or a T. But with this proposal, there's always a third option: It could be nil. So, right off the bat this proposal gives weaker guarantees than what people really want.

Then you'd have to jump through a couple of hoops to make such a Result type actually safe. There are some obstacles:

So you'd get something like

type Error struct { E error }
type Success[T any] struct { Val T }
type Result[T any] interface { Error | Success[T] }

There's a lot of overhead in using this, over returning (T, error) - not just because you need to type-assert, but you also need to wrap and unwrap the individual structs.

And the value you get is saying that a Result[T] is either an Error or Succes[T], fair enough. But what does that get you, except documentation? The compiler won't actually type-check that you correctly type-switch on it exhaustively. So, how is this really any better than just returning an any and documenting that it's either an Error or a Success (or construct a different non-union interface for wrapping)?

I think the value of this proposal can only really come in enforcing constraints on inputs, really. There is some value in knowing that you aren't being given anything but one of these N things. The information that a function only returns a finite set of types isn't super useful without further infrastructure (like match statements and exhaustiveness checks and the like).

ncruces commented 1 year ago

One advantage, to me, is if it makes it easier to plug things that return Result[T] into things that accept Result[T] (or, possibly, ...Result[T]) than it is with (T, error). Same for Option[T] vs (T, bool).