-
Notifications
You must be signed in to change notification settings - Fork 17.7k
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
proposal: Go 2: sum types using interface type lists #41716
Comments
I don't understand this. If all types in the type list appear as cases, the default case would never, trigger, correct? Why require both? Personally, I'm opposed to requiring to mention all types as cases. It makes it impossible to change the list. ISTM at least adding new types to a type-list should be possible. For example, if I think requiring a default case is a good idea, but I don't like requiring to mention all types as cases. There is another related question. It is possible for such a sum-value to satisfy two or more cases simultaneously. Consider type A int
type X interface {
type A, int
}
func main() {
var x X = A(0)
switch x.(type) {
case int: // matches, underlying type is int
case A: // matches, type is A
}
} I assume that the rules are the same as for type-switches today, which is that the syntactically first case is matched? I do see some potential for confusion here, though. |
[edited] @Merovius It does say "...and if there are any types in the type list that do not appear as cases in the type switch." Specifically, there is no comma between "default case" and "and". Perhaps that is the cause for the confusion? Regarding the multiple cases scenario: I think this would be possible, and it's not obvious (to me) what the right answer here would be. One could argue that since the actual type stored in |
Makes sense. I can see how the language is a little ambiguous, the point is it's a compile error if both of those conditions exist.
It occurred to me that tooling could spot when a type switch branch was invalid, i.e. the interface type list only contains A and B and your switch checks for C, but it seems best to not make that a compiler error. A linter could warn about it, but being overly restrictive here might harm backwards-compatibility.
I think it makes the most sense for the type switch to behave consistently. It's not clear to me how the type switch would be any different except that the interface being switched on has a type list. You can know at compile-time what branches should be in the switch, but that's it. Overall I'm in favor, I think the proposal is right on the money. They function like any other interface, (no operators) and zero value is |
@griesemer Ah, I think I understand now. I actually misparsed the sentence. So AIUI now, the proposal is to require either a default case or to mention all types, correct? In that case, the proposal makes more sense to me and I'm no longer confused :) I still would prefer to require a default case, though, to get open sums. If it is even allowed to not have a default case, it's impossible to add new types to the type-list (I can't know if any of my reverse dependencies does that for one of my exported types, so if I don't want to break their compilation, I can't add new types). I understand that open sums seem less useful to people who want sum types, though (and I guess that's at the core of why I consider sum types to be less useful than many people think). But IMO open sums are more adherent to Go's general philosophy of large-scale engineering and the whole gradual repair mechanism - and also more useful for almost all use-cases I see sum types suggested for. But that's just my 2¢. |
@Merovius Yes, your new reading is correct. |
Could you clarify why For example, they could be left out by default, or included by writing I understand that the zero value gets trickier if we remove the possibility of nil, which might be the reason behind always including nil. What do other languages do here? Do they simply not allow creating a "zero value" of a sum type? |
To add to my comment above - @rogpeppe's older proposal in #19412 (comment) does indeed make |
@mvdan as far as I'm aware other languages with sum types do not have the notion of a zero value and either require a constructor or leave it undefined. It's not ideal to have a nil value but getting something that works both as a type and a metatype for generics is worth the tradeoff, imo. |
I guess (as a nit) |
So this https://go2goplay.golang.org/p/5L7T8G9rfLD would print "something else" under the current proposal, correct? The only way to get that value is reflect? |
@jimmyfrasche Correct. This proposal doesn't change the way that type switches operate, except for the suggested error if there are omitted cases. |
So that means this code would panic? https://go2goplay.golang.org/p/vPC-qtKb7VO |
I'd like to reiterate my earlier suggestion: https://groups.google.com/g/golang-nuts/c/y-EzmJhW0q8/m/XICtS-Z8BwAJ |
Not terribly excited about the new syntax.
Well, it panics without the type list. But I get your point, the interface then allows a value for which the compiler won't enforce a branch in a type switch. Could we perform implicit conversion to one of the listed types when you assign into the interface? The only case I can think of where that's weird is when the interface has methods that those underlying types don't have, i.e. you have While I really like the idea of unifying interface semantics by allowing type lists in interfaces used as values, rather than just as constraints, perhaps the two use cases are different enough that the interfaces you'd use for each vary significantly. Maybe this problem we're discussing isn't one that we'd encounter in real code? It might be time to break out some concrete examples. |
Any explicit syntax would work. I just had to choose something semi-reasonable to write the idea down. At any rate, it wouldn't need to be used very often but having the choice let's everything work reasonably without either use being hindered by the existence of the other. |
@Merovius Correct: that code would panic. I think it's worth discussing whether that would in fact make these sum types significantly less useful. It's not obvious to me, because it's not obvious to me that sum types are often used to store values of types like |
You could always work around it by using |
@ianlancetaylor Point taken. I can't personally really provide any evidence or make any strong arguments, because I'm not convinced sum types in and off itself are actually very useful :) I was trying to extrapolate. Either way, I also find it objectionable on an aesthetic level, to single out predeclared types in this way - but that's just subjective, of course. |
Regarding changing a type list being a breaking change: If the type list contains an unexported type, then the rule in @ianlancetaylor's proposal effectively requires that all type switches outside the package containing the sum type contain a default case. For example, package p
type mustIncludeDefaultCase struct{}
type MySum interface {
type int, float64, mustIncludeDefaultCase
} Regarding type T interface { type int16, int32 }
func main() {
var x T
// None of these cases will execute, because x is nil.
switch x.(type) {
case int16:
case int32:
}
} I personally would prefer a design in which the zero value of a sum is the zero value of the first type in the sum. It is easy to add an additional "nothing here" case when desired, but impossible to remove a mandatory |
In general I'm in favour of this proposal, but I think there are some issues that need to be solved first.
If type switches aren't changed at all, then I don't see how this rule is useful. It feels like it's attempting to define that switches on type-list interfaces are complete, but it's clear that they can never be complete when the type list contains builtin types, because there are any number of other non-builtin types there could be. In general, even putting the above aside, I don't think the requirement for a type switch statement to fully enumerate the cases or the requirement to have a default fits well with the rest of the language. It's common to just "fall off the bottom" of a switch statement if something doesn't match, and that seems just as apt to me for a type-list type switch as with any other switch or type switch. In general, the rule doesn't feel very "Go-like" to me. What about type assertions involving type-list interfaces. Can I do this?
If not, why not? If so, why is this so different from a type switch with a single case and no default branch? What about this (type asserting to a type list interface) ?
If that works, presumably this could be used to test the underlying type of the dynamic type of an interface, which is something that's not easy to do even with reflect currently.
I understand why this rule is proposed - using the same rule in both cases is important for consistency and lack of surprises in the language. However, ISTM that this rule gives rise to almost all the discomfort I have with this proposal:
If we don't allow an interface type with a type list to match underlying types too, then you end up with surprises with assignments in generic functions. For example, this wouldn't be allowed, because F might be instantiated with a type that isn't
How about working around this by adding the following restriction: Only generic type parameter constraints can use type-list interfaces that contain builtin types. So the above example would give a compile error because The above restriction would make almost all the issues go away, I think - albeit at the cost of some generality.
Allowing operators is only a problem for binary operators AFAICS. One thing that might be interesting to allow is operators that do not involve more than one instance of the type. For example, given:
I don't think that there would be any technical problem with allowing:
I think that always having |
As far as I can tell, this proposal nearly parallels the defined-sum interface types in my previous writeup. There is one key difference that I would like to explore: assignability. To me, assignability is what makes interface types coherent at all: it is what causes interface types to have regular subtyping relationships, which other Go types lack. This proposal does not mention assignability at all. That seems like an oversight. In particular:
@mvdan: for me, the assignability properties are what lead to the conclusion that all sum types should admit |
I believe that this proposal is compatible with (in the sense that it would not preclude later addition of) the sum interface types from my previous writeup, which I think are a closer fit to what most users who request “sum types” have in mind. |
This constraint seems like a mistake to me. Due to the underlying-type expansion, even a I think that either the |
Would unary operators be defined on the types if possible? ie:
I would assume not since unary operators are defined to expand to binary operators, however the unary operators are valid to use since the other operand is untyped. Although this could result in unexpected behavior for sum types with |
Finally, I would like to note that this proposal on its own would expose an (existing) inconsistency in the generics design draft: the semantics of a type-list interface used as a constraint would be markedly different from the semantics of any other interface type used as a type constraint. In particular, unlike other interface types, a type-list interface would not satisfy itself as a type constraint. If it did, then the type constraint (See https://github.com/bcmills/go2go/blob/master/typelist.md#type-lists-are-not-coherent-with-interface-types for more detail.) |
@rogpeppe Thanks. I remain absolutely convinced that it is not acceptable to require type conversions for |
I still think explicit syntax is the way to go, but I'm warming up to the possibility of two rules
It doesn't feel ideal to have two rules but as long as they're both simple maybe it's not too bad. |
Sum types in Go is really good idea. Using sum type is a good way to define different states using types. type Result[T any] interface {
type T, error
} can be I think it should not be type SomeVariant sturct{}
type SomeVariant2 sturct{}
type SumType interface {
type SomeVariant, SomeVariant2
}
func var() {
var s SumType // compiler error: s is uninitialized
var s2 SumType = SomeVariant{} // OK
}
func result() (r SumType) { // compiler error: r is uninitialized
return
}
func result2() (r SumType) { // OK, r is always initialized
r = SomeVariant{}
return
}
func result3() SumType { // OK, r is always initialized
if condition() {
return SomeVariant{}
}
return SomeVariant2{}
}
type SomeType struct {
S SumType
value int
}
func insideStruct() SomeType {
var s SomeType // compiler error: field S is uninitialized
var s2 *SomeType // OK, if pointer is nil, then dereferencing or s2.S will cause panic
s1 := SomeType{value: 10} // compiler error: field S is uninitialized
s2 := &SomeType{} // compiler error: field S is uninitialized
s3 := SomeType{S: SomeVariant{}} // OK
}
type Constr interface {
type []byte, SomeType
}
func insideTypeSum[T Constr]() T {
var zero T // compiler error: T can be SomeType and is uninitialized
return zero
} Also, if we really need type SumType interface {
type nil, SomeVariant
} And there is intersting case to use this feature: type box[T any] struct {
value *T
}
func (b box[T]) Get() *T {
return b.value
}
type NonNil[T any] interface {
type box[T]
Get() *T
}
func Take[T any](t T) NonNil[T] {
return box[T]{value: &t}
}
So, there no way to create |
That would be a huge change to Go, definitely much more so than the proposal here. You have left out so many edge cases, e.g. reflect.Zero, arrays/slices, maps, new and so on. Your idea would be better served by its own proposal (if one doesn't already exist), where its feasibility can be discussed without adding noise to this much narrower proposal. |
I did not propose deny zero values for all types or types inside type list. I proposed to deny creation of type sum with type ByteString interface {
type []byte, []rune, string
} Variable of type var slice []SumType // OK, there are no elements - no zero value type sum
var map map[string]SumType // OK, there are no elements - no zero value type sum
var channel chan SumType // OK, there are no elements - no zero value type sum
var array [2]SumType // compiler error: elements are uninitialized
I am not sure about how reflection would be work in Go2, but I think |
I'm afraid it would be a huge change. Zero values crop up in Go in many places.
What about:
What's the result of
What's the value of You would have to forbid use of In short, your suggestion is not viable. All types in Go must have a zero value. In #19412, I suggested that the zero value could |
Got me wondering if nil should not be made a special type that belongs to every sumtype. The "bottom" I think it's called in type theory. Everything I read in the proposal summary seems fine to me so far. Especially if we consider types as merely named interfaces or in Go, interfaces with a method that returns a unique name. Issue is that people would not like having to deal with nil everywhere but it might be needed for function composition with sumtype arguments, amongst other things. Also means that if we wants sumtypes of interfaces, they have to have fully orthogonal method sets (only a union of disjoint sets of types should work, a traditional go interface being an intensional encoding of a set of types) Also important to note that set union is commutative. So should be interfaces. The zero-value of a sumtype being the zero-value of the first or second type component would preclude that. |
If we are going to use I'm not saying that we need to use |
What we're calling "underlying" in this thread, really just refers to a type's kind. Types in Go1 are invariant.
Having In my opinion, the interesting question is not how to
In my opinion: introducing underlying-matching fundamentally challenges Go1's design and opens a wholly new problem space much larger than what has been considered in this thread so far. |
I'm having trouble understanding what you are trying to say.
Not really. kind doesn't distinguish between different structs, but underlying does. Also, "underlying type" is an established concept from the go spec, kind isn't.
Perhaps. Though I think it is useful to be more specific than just say "we introduce a type hierarchy". In particular, Go already has actual subtyping (the spec calls it "assignability", mainly affected by interface satisfaction), so having type hierarchies in general isn't necessarily a bad thing. In this particular case, the matching only applies to generic type parameters. So while there are some contexts in which you can use a
I'm not sure what you mean here. "Invariance" is a property of type-constructors, not of types. And as far as I can tell, nothing really changes in this regard - Go won't get variant type constructors with the generic design, underlying type matching or not. You won't be able to use a You might intend to say that Go has no subtyping, but see above - it already does and has been since the Go1.0.
Why would we say any of that? I don't see anything in the design or this proposal that would suggest structs, funcs or maps/slices aren't allowed, under exactly the same rules.
This is an interesting question. A priori, I would assume "no" - the underlying type of
Perhaps. But I think it's also important to acknowledge that without it, type- |
Yes, I think the confusion stems from the understanding of what underlying means. I think it's suposed to be used in case of a sumtype defined using the built-in types. So when the underlying is a built-in type. Because if we take the initial post from Ian, if I define [edit] unless as @thwd was mentioning a type hierarchy which indeeed exist, we have
Also, yes, pointer dereference operator is not an issue. Think of it as a function from int to a Typed value and remember that there is no overloading in Go. No covariance/ contravariance either. No automatic conversion. All this is subtly linked. Then, it just works because MyInt and int are different: they define disjoint sets of values. Add(MyInt, int) can be transformed to Add(MyInt, MyInt) but Add(MyInt, OurInt) can not be specified. (or another name for this function/method/operator should be used, i.e. can't use the "+" symbol because no operator overloading and it would be left at the discretion of the user to define what that even means think adding celsius + meters) For type switches, again, it's important that the types in a sumtype/union type be disjoint in terms of conxeptual method set (still taking a type as a conceptual go interface which, if not built-in, has a unique-name returning method) . |
The definition that applies here is the one in the language spec (https://golang.org/ref/spec#Types):
|
I don't agree that that is important. This proposal describes types that function more as unions than as algebraic data types, and that's ok: the concrete types themselves function as algebraic constructors, so the sum types don't need to also provide that functionality. |
Yes, thank you. Indeed I had also gone back to the spec and figured out underlying is defined there. The proposal does define a subtyping relationship then as @thwd was alluding to in terms of type hierarchy. I do no think it's necessarily problematic after thinking about it. As for your second point, the issue that could possibly occur for me is when the types in a union are interfaces. How to type switch on that if a concrete type implements several interfaces. |
One solution for the type switch problem for the union proposed in this issue wiuld be to require the type switch to consist of all members of the union, each nominally, nothing more and nothing less. If any member is not exported then the type switch is only allowed in the defining package. This idea is simple and Go-like, I think. |
@ianlancetaylor @griesemer To fully replace type lists and not just the list part would require changes to interfaces types to match fields as well as methods (unfortunately this change to the language seems to have already been ruled out #23796). Below is an example of rewriting the initial example using a type union, only replacing the list is types lists, followed by an example of replacing type lists with union type where interfaces can match fields. Both these examples achieve the semantically of types lists, but also allow for discriminated unions to be used in other places. Example of replacing list with union type type MyInt int
type MyOtherInt int
type MyFloat float64
type MyNumber union {
MyInt
MyFloat
}
type Number union {
int
float64
}
type I1 interface {
type MyNumber
}
type I2 interface {
type Number
} Example of replacing type lists with union type type MyInt int
type MyOtherInt int
type MyFloat float64
type MyNumber union {
MyInt
MyFloat
}
type Number union {
int
float64
}
type I1 interface {
MyNumber
}
type I2 interface {
Number
} Above the unions are using embedded branches (a branch being analogous to a field in a struct or a signature in an interface). Discriminated unions would be similar to interfaces in their implementation. I appreciated this comment is unlikely to get much traction as the prevailing opinion seems to be strongly against unions and fields in interfaces. I was motivated to write this as Robert explicitly called into question type lists in interfaces. |
@millergarym, alternatives involving discriminated unions have been discussed at length on #19412. This issue is specifically focused on generalizing the type-list interfaces suggested by #43651. I don't think that the semantics required by #43651 are compatible with the |
@bcmills sorry, didn't read the initial message well enough.
Picking up on
and responding to
In simple algebraic type systems unions are sum types. I appreciate the issues are deeply embedded in the Go type system
But isn't generic and Go2 an opportunity try tack these issues? |
The current generics proposal is explicitly backward compatible. It is unlikely that Go will ever make large backward incompatible changes, as discussed at https://go.googlesource.com/proposal/+/refs/heads/master/design/28221-go2-transitions.md. |
I already posted this in the thread of the type parameters proposal, but I think here is the correct place. Type lists + interfaces instead of type lists in interfacesCurrent type parameters design
Usage:
Suggested change
Usage:
Interfaces are left untouched. |
Retracting in favor of #45346 (which may in time lead to another proposal similar to this one, but different). |
I filed #57644 to update this for the final implementation of generics adopted into the language. |
This is a speculative issue for discussion about an aspect of the current generics design draft. This is not part of the design draft, but is instead a further language change we could make if the design draft winds up being adopted into the language.
The design draft describes adding type lists to interface types. In the design draft, an interface type with a type list may only be used as a type constraint. This proposal is to discuss removing that restriction.
We would permit interface types with type lists to be used just as any other interface type may be used. A value of type
T
implements an interface typeI
with a type list ifT
includes all of the methods inI
(if any); andT
or the underlying type ofT
is identical to one of the types in the type list ofI
.(The latter requirement is intentionally identical to the requirement in the design draft when a type list is used in a type constraint.)
For example, consider:
The types
MyInt
andMyFloat
implementI1
. The typeMyOtherInt
does not implementI1
. All three types,MyInt
,MyOtherInt
, andMyFloat
implementI2
.The rules permit an interface type with a type list to permit either exact types (by listing non-builtin defined types) or types with a particular structure (by listing builtin defined types or type literals). There would be no way to permit the type
int
without also permitting all defined types whose underlying type isint
. While this may not be the ideal rule for a sum type, it is the right rule for a type constraint, and it seems like a good idea to use the same rule in both cases.Edit: This paragraph is withdrawn.
We propose further that in a type switch on an interface type with a type list, it would be a compilation error if the switch does not include adefault
case and if there are any types in the type list that do not appear as cases in the type switch.In all other ways an interface type with a type list 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 when using such a type as a type constraint. This is because in generic code 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 list, 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 a type list 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, namelynil
. Sum types in most languages do not work this way, and this may be a reason to not add this functionality to Go.As I said above, this is a speculative issue, opened here because it is an obvious extension of the generics design draft. 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. Thanks.
The text was updated successfully, but these errors were encountered: