-
Notifications
You must be signed in to change notification settings - Fork 17.8k
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: spec: finite type set interface as union type #70752
Comments
I think the final comments on #54685 apply here. This proposal describes a change, but lacks strong reasoning on why this proposal should be accepted over the other ones, why the choices here are reasonable (nil default, type switch exhaustiveness), and what this proposal enables, taking into account the comments on the previous proposals. |
The "nil default" choice is reasonable because type union would still be an interface, so it makes sense it behaves like one in all scenarios, except in switch type checks - which seem to be the biggest motivation behind union types entirely. In any other context, union types don't give much benefit over interfaces. Any decision to leave out nil as possible union value leaves out a question of zero value - which would either make it behave differently from interfaces altogether, or create implicit behavior that is hard to predict. In conclusion, "nil default" is the most similar with current way the interfaces work, and make the union type addition simpler - and exhaustive type check is the main benefit type union would have over regular interfaces. Consider the following proto definition: syntax = "proto3";
package example;
option go_package = "./example";
message Request {
oneof data {
bytes bdata = 1;
string sdata = 2;
}
} When compiled to Go code, type Request struct {
// Types that are assignable to Data:
//
// *Request_Bdata
// *Request_Sdata
Data isRequest_Data `protobuf_oneof:"data"`
}
type isRequest_Data interface {
isRequest_Data()
}
type Request_Bdata struct {
Bdata []byte `protobuf:"bytes,1,opt,name=bdata,proto3,oneof"`
}
type Request_Sdata struct {
Sdata string `protobuf:"bytes,2,opt,name=sdata,proto3,oneof"`
}
func (*Request_Bdata) isRequest_Data() {}
func (*Request_Sdata) isRequest_Data() {} The Go gRPC implementation uses an ad-hoc union type by defining an interface with an empty This proposal will allow a straightforward definition of Data: type Request struct {
// Types that are assignable to Data:
//
// *Request_Bdata
// *Request_Sdata
Data isRequest_Data `protobuf_oneof:"data"`
}
type isRequest_Data interface {
*Request_BData | *Request_Sdata
}
// ... In case proto is extended with additional oneof, we will get compiler error wherever we don't handle the new case: message Request {
oneof data {
bytes bdata = 1;
string sdata = 2;
int32 idata = 3;
}
} var r &example.Request
switch r.Data.(type) {
case *nil:
case *Request_Bdata:
case *Request_Sdata:
// error: type switch of type union not exhaustive
} In the currently-generated Go code, there is no way to verify that we've handled all cases. The exhaustive switch type check is the very thing that distinguishes union types from the interfaces. I've spent significant time reading the other proposals. In fact, comment #54685 (comment) is the one that inspired me to try to come up with the simplest change to Go which would introduce the exhaustive type-check mechanism. I'd be happy to elaborate more on why this proposal should be accepted, but my main reason is that it is more simple, while intruducing the very functionality that isn't included in the existing interface implementation - the ability for compiler to enforce that we've exhaustively check all cases. If we don't want that behavior, we can use ordinary interface (or Any feedback on why this proposal is worse than others would be appreciated, but in the meantime I will actively compare the proposal with others and write a deeper analysis in a few days. |
I have in the past been an advocate for the use-case of "closed interfaces" or similar -- across various different proposals -- so my default outlook on this is positive. 😀 It seems like an implication of the details of this proposal is that a value of a "type union" interface would support no operations except what would be supported for For example, a value of type type Integer interface {
int64 | int32
}
func example1(a, b Integer) Integer {
return a + b // invalid
}
func example2[T Integer](a, b T) T {
return a + b // valid
} Does that match what you were intending? If so: while I don't think this is disqualifying, it does represent an additional inconsistency in the treatment of type parameters vs. interface values, which seems like a cost worth capturing in the relevant section of the proposal. The "equality tests that might panic if the dynamic type is noncomparable" is probably also a detail worth thinking about and specifying directly. I assumed it would follow the rules for We could say that a "type union" interface type is comparable only if all of the types in the union are comparable, so that Of course, that adds another element of risk to the "exported type unions could break dependent code when extended": extending a type union that was previously all comparable types with one that isn't comparable would break not only exhaustive type switches but also any existing equality tests. Again, I don't think it's necessarily disqualifying but it is still a cost. Overall it's probably simpler to just keep the existing rules that interface-typed values are always comparable but might panic at runtime, for consistency's sake. |
The core type proposal #70128 has significant implications for this proposal. It is currently on hold. If it does go forward at some point, IMO it would significantly shift what operators would be natural for to allow on |
Yes, all you've written is completely correct.
I've added the example to the costs.
While I see the reasoning behind, I personally think it would be more confusing 1) being inconsistent with interface values 2) being sometimes comparable, sometimes not, depending on the types in the union.
Agreed. Additionally, to build onto the Integer example, |
What happens if a generic function tries to accept different union types? type Intish interface { int | uint }
func Add[N Intish](a, b N) N
var a, b Intish = int(1), uint(2)
Add(a, b) // This can't work, right?
// But then how do I write a generic function
// that does accept Intish interface values among other types?
type Floatish interface { float32, float64 }
func StoreNumber[ what goes here to accept Intish or Floatish? ](a Numeric) error |
This is just a more restricted version of #57644? |
@timothy-king that's what the initial thought was at some point but I think that if we consider operators as being methods +(T, T) T (Also why it should be better to make sure that such union values with untyped nil inside (i.e. empty) are not used, only declared. Because untyped nil has no such operator and does not belong to the typeset (well, it's "untyped" nil, it has no type I guess) Also, long-term thinking, not even sure that I would personally allow operators on unions with a singleton typeset. Maybe I've read too fast but this proposal seems like a dup of #57644? |
I guess we'd also need to figure out how this new kind of interface value participates in Some initial thoughts on that:
Writing this out did make me realize that this proposal seems to create a new sort of interface type that acts like I wonder if there's any existing code out there which is making assumptions about how interface types behave under |
I don't agree. If you have two different values of that interface type, one might be |
I'm inclined to close this as a dup of #57644. Any argument against that? |
@mateusz834 @atdiar In a nutshell, this is a very restricted and simplified version of #57644. @earthboundkid That is certainly a good question. Regular (method-set) interface values always fulfill themselves-as-type-constraints. It would make sense for that to be the case for union types too. type Intish interface { int | uint }
type Floatish interface { float32 | float64 }
func IncrementIntish(v Intish) Intish {
return v + Intish(1) // error
}
func IncrementNumber[T Intish | Floatish](v T) T {
return v + T(1) // should this be allowed? can T be Intish interface?
} So union types cannot fulfill themselves-as-type-constraints. Type constraints were implemented when the concept of type-unions-as-values did not exist, so they made an assumption that the underlying type of the value will always be exactly one of the specified constraint types. We cannot break that assumption. I think the only reasonable option would be, if we wanted to get either Intish or Floatish, to allow specifying union types as part of the union type definition, e.g. func DoSomething(v interface { Intish | Floatish }) {}
var v1 interface {int, float32}
DoSomething(v1) // ok
var v2 interface {uint, float64}
DoSomething(v2) // ok That would also mean we need a type switch for the Intish type (which would be equivalent to @ianlancetaylor
If everything said here was also discussed there, feel free to close this one. |
Lol, I'm a philosophy PhD, and we have literally recreated from first principles the problem of whether the Platonic forms are self-describing: Is Goodness good, is Twoness two, etc. Twenty five hundred years later, it's still a hard problem! |
@paskozdilar type Value2 interface{
string | bool | float64 | []Value | map[string]Value | Value
}
type Value interface{
discriminant() // == String | Bool | Object | List
} f(v Value) discriminant() {) type String string ... (same for Bool, Object, List, they implement the discriminant method) This is a basic representation of a use-case I have. Currently I can only use Value which doesn't allow basic types in the interface (string, bool, float64 etc) and therefore requires a conversion. It makes the API a bit heavy. Ideally, I would like to migrate the code in a backward-compatible way by using Value2. That would make the typeset of Value2 virtually infinite. |
@ianlancetaylor Operator + was kinda an unfortunate choice on my part and opened too large of a can of worms. My broader point though is that if core types are eliminated, that does remove hurdles for supporting more operators on interface unions. |
As others have alluded to, I don't think exhaustive type checking is a good fit for Go. In Go we aim for strict backward compatibility. If we support exhaustive checks for type switches on union types, then adding any type to a union type is an incompatible API change, because it will break code that uses that type. That's going to be difficult to work with. In the compatibility doc we were careful to ensure that adding fields to structs or interfaces to methods did not cause an incompatible API change. That is because Go is designed for programming at scale, and one aspect of programming at scale is that a change in one part of a large program should not cause unexpected breakage in another part of that program. |
@atdiar type Foobar struct{}
func (*Foobar) Foo() {}
func (*Foobar) Bar() {}
type Fooer interface {
Foo()
}
type Barrer interface {
Bar()
}
type MyUnion interface {
Fooer | Barrer
}
var v MyUnion = &Foobar{}
// which one of the cases above get executed?
switch v.(type) {
case Fooer:
case Barrer:
} Disallowing method-set-interfaces is the only way I could think of that would make union-type-interfaces consistent. @ianlancetaylor // exported.go
type Foobar interface{
Foo()
Bar() // added after code was already used in usercode.go
}
// usercode.go
type UserType struct{}
func (*UserType) Foo() {}
func DoSomething(f exported.Foobar) {}
func DoSomethingWithUserType(v UserType) {
DoSomething(v) // UserType does not implement Foobar (missing method Bar)
} But I can see why we would want to avoid opening further doors towards breaking user code. I believe some method of achieving something-like-exhaustive-type-checking is worth having in Go. Two real-life examples come to mind immediately: |
@paskozdilar the solution is to consider that there is a missing case that the compiler needs you to add, namely: interface{
Fooer
Barrer
} Meaning that each union term and each case must be totally discriminating. |
@atdiar Example 1) // compiler enforces exhaustive type switch
type Fooer interface { Foo() }
type Barrer interface { Bar() }
type Bazzer interface { Baz() }
type Quxxer interface { Qux() }
type Quuxxer interface { Quux() }
type MyUnion interface {
Fooer | Barrer | Bazzer | Quxxer | Quuxxer
}
var v MyUnion
case v.(type) {
// There are now 32 cases that need to be covered.
// If we add another interface to the union, there will be 64. Then 128. And so on.
} Example 1) // compiler doesn't enforce exhaustive type switch
type Foobar struct{}
func (*Foobar) Foo() {}
func (*Foobar) Bar() {} // added later
type Fooer interface { Foo() }
type Barrer interface { Bar() }
type MyUnion interface {
Fooer | Barrer
}
var v MyUnion = &Foobar{}
case v.(type) {
case Fooer:
// this code will not execute after Bar() method is added to Foobar, causing silent bugs
case Barrer:
} Neither case is truly acceptable to me. |
@paskozdilar yes. This is a feature and not a bug. How often are you going to use overlapping cases in reality? Even if you were to use code generation, if you have to write such unions then something is wrong. |
I respectfully disagree. If you really want to have interfaces in a union, in context of this proposal, you can wrap them in a struct: type Value interface { /* ... */ }
type ValueWrapper struct {
Value Value
}
type MyUnion interface {
int | string | ValueWrapper
} This will work similarly, while avoiding all the complexity of having interfaces directly in the type union. |
@paskozdilar bad design would be to use a union of that many overlapping single method interfaces anyway. Using a ValueWrapper defeats the purpose of simplifying APIs and backward compatibility if a conversion is still needed. |
Nevertheless, the very foundation of this proposal is union-type-interfaces not being able to embed method-set-interfaces. If you're interested in discussing an alternative design, it would be more appropriate to create a new proposal for that. |
There is already a proposal that could eventually fit with the real use case I've presented above. My intent is simply to provide you with use cases that were obviously in your blind spot. That's all. Thanks for the discussion. |
A flattening of unions when included in another union, is pretty surprising and not what I'd expect. In the above I would expect switch v := numberish2.(type) {
case Intish:
switch v {
case int: ...
case uint: ...
}
case Floatish:
switch v {
case float32: ...
case float64: ...
}
} |
This is a consequence of reusing interfaces to express unions: an interface value cannot dynamically contain another interface value. Granted, I would expect that nested type switches like you mean in your example should still work under this proposal (or #57644), but the cases in the outer switch would be interface-to-interface conversions. |
I sort of agree. type Union1 interface {
int | uint
}
type Union2 interface {
int | float32
}
type Union3 interface {
Union1 | Union2
}
var v Union3 = 42
switch v.(type) {
// which case gets executed here?
case Union1:
case Union2:
} Any order we choose will make type unions not commutative - i.e. In practice, we could use tools like |
Go Programming Experience
Intermediate
Other Languages Experience
C, C++, Python, TypeScript
Related Idea
Has this idea, or one like it, been proposed before?
#54685
#19412
Most of which suggest introducing new syntax, such as
type MyUnion = Type1 | Type2 | ...
Does this affect error handling?
No.
Is this about generics?
No.
Although this proposal builds on the type set concept introduced in generics, it is not about generics itself.
Proposal
The introduction of generics in Go has introduced the concept of type constraints, which have syntax similar to interfaces, but are not interchangable:
Previous proposals of re-using type constraints for union types have been rejected, the reason being the inherent inability of a compiler to produce exhaustive type search when the number of types that fit the constraint is infinite:
This proposal suggests permitting a restricted set of type constraints to be usable as Union types.
The "restricted set of type constraints" here is defined as a type constraint which only contains a union of "exact types" - meaning builtin types, structs, pointers, functions and named types whose underlying type is a builtin type, struct, pointer or function - and other union interfaces. Method-set interface types are not allowed. Structs that embed interface types are allowed as a workaround.
I.e.
~Type
syntax is not allowed and method sets are not allowed.This restriction covers the basic use case of unions - exhaustive type switch.
Since type union would essencially be an interface, its default value would be nil, and
nil
could be checked in the switch statement, just like with regular interfaces.Example, using go/ast as inspiration:
Alternatively,default
case could be forbidden completely for type unions. That would be the conservative thing to do - as we can always allowdefault
post-hoc, but we couldn't forbid it once it's already in the language.EDIT: Enforcing exhaustive type switch by compiler is incompatible with Go's approach towards gradual code repair. Therefore, checking of exhaustive type switches of union types should be left to other tools.
A union of two unions would contain union of all their elements:
Open question is whether to allow type unions as cases in type switches. Allowing them could cause ambiguities:
We could possibly deal with this in a similar manner that net/http deals with routes: by making the most specific type set win - e.g.
int
overIntish
,float64
overFloatish
- and disallowing type sets that are equally specific.I personally would prefer to simply disallow type union interfaces in
case
statements whatsoever, to avoid having to deal with this set theory complexity.Advantage of this approach is the simplicity of syntax and its similarity to already-existing concepts of interfaces and type constraints.
Disadvantage of this approach would be similar of the disadvantages of generics - separation of "interface" syntax into yet another form - type union.
So the
type Foo interface
syntax could mean three different things, depending on its definition:Currently, interfaces are subset of type constraints.
In this proposal, union types would be also subset of type constraints, disjoint from interfaces.
Type unions would also play well with type parameters:
Since type parameters must be specified on instantiation, the particular instance of ArrayOfSet[T] type union would still be a finite set of exact types.
Language Spec Changes
We would need to define a "type union" concept in a similar manner to type constraints, e.g.:
Informal Change
Type unions are a special kind of interfaces - interfaces which only specify a union of exact types:
They are special because
compiler(e.g.) go vetenforces that a switch statement must checkwarns if a type switch statement has not checked all possible cases, including nil:This is useful when in business domain code where we want to make sure we've covered all possible cases in many different parts of code when we add a new type to the union.
Is this change backward compatible?
Yes.
Since embedding exact types in ordinary interfaces is not allowed, union types would be completely disjoint from ordinary interfaces.
Since union types would be a subset of type constraints, they could be used as type parameters.
The only difference would be the ability to use union types in the way ordinary interfaces are used - with the addition of
compiler enforcingtools being able to check exhaustive type switch for them.Please see sections above for examples.
Orthogonality: How does this change interact or overlap with existing features?
I believe it fits well within the current path Go is evolving, ever since generics were introduced, together with type sets being the underlying concepts of both interfaces and type constraints. Type unions are a special case of type sets - finite type sets.
Would this change make Go easier or harder to learn, and why?
It would make Go harder to learn, as there would be yet another context of interface syntax to learn.
But I believe people who understand type set concept - required for also understanding generics - will easily grasp the concept of type unions.
Cost Description
Compiler would need to be made aware of union types
and would need to enforce exhaustive checks.Exported type unions could break dependent code when extended.Although regular interfaces may also break dependent code when extended, this only happens if user uses an exported interface in combination with their own type (or third party type) that implements the interface.In a nutshell - anyswitch
statement on exported type unions would break when type union is extended.Although one might argue that this is exactly the point of union types.Additionally, this change will increase the inconsistency between treating interface types and type parameters:
Changes to Go ToolChain
vet, gopls
Performance Costs
Compile time cost could be increased depending on implementation. Run time cost would be same as with regular interfaces.
Prototype
Union types could be implemented as ordinary interfaces, with the addition of checking that the switch statements
are exhaustivecontain only exact types. The underlying implementation could be exactly the same as interface implementation.The text was updated successfully, but these errors were encountered: