New language features, especially significant ones like generics, inevitably come with some caveats. Here are two.
Have a look at the following code. Do you think it compiles? If you think it doesn't compile, what might be the reason?
type Sizer[T any] interface {
Size() uintptr
}
type Producer[T any] struct {
val T
}
// Producer implements the Sizer interface
func (p Producer[T]) Size() uintptr {
return unsafe.Sizeof(p.val)
}
func (p Producer[T]) Produce() T {
return p.val
}
func Execute[T any](s Sizer[T]) {
switch p := s.(type) {
case Producer[T]:
fmt.Println(p.Produce())
default:
panic("This should not happen")
}
}
func main() {
Execute[string](Producer[string]{"23"})
Execute[string](Producer[int64]{27})
}
Look at the signature of Execute()
in particular and at the two Execute()
calls inside main()
. When Execute()
is instantiated with a string
type, then the type of parameter s
should be a Sizer[string]
, right? Similar to this pseudo-code:
func Execute[string](s Sizer[string])
Sizer[T]
is an interface with a method Size()
, and type Producer[T]
implements that interface. Passing a Producer[string]
to Execute[string]
, as in the first Execute()
call in main()
, therefore should work as advertised.
In the second call to Execute[string]
, the parameter is of type Producer[int64]
. This certainly doesn't compile—or does it?
Try it in the Go Playground. (The code was written for, and tested with, Go 1.18.)
Back from the playground? Ok. You should have observed the following.
Execute
succeeds.Execute
also succeeds, but inside Execute
, the type switch executes the default
case.Why does this happen? Why does the wrong type parameter trigger no error at compile time?
Maybe you noticed this already. The Sizer
interface declares a type parameter T
but does not use it anywhere. The type parameter may look quite natural in this context, because, after all, Producer
has one, too, and Producer
shall implement Sizer
, so Sizer
also seems to need one.
In fact, since Sizer
's type parameter is unused inside the definition of Sizer
, we can safely omit it—from the interface definition, as well as from Execute()
's function signature.
Here is the cleaned-up code. It behaves in exactly the same way as the original code, but is easier to read.
type Sizer interface {
Size() uintptr
}
func Execute[T any](s Sizer) {
switch p := s.(type) {
case Producer[T]:
fmt.Println(p.Produce())
default:
fmt.Printf("s implements Sizer. That's all we know about s. Size is: %d bytes.\n", s.Size())
}
}
Note that Execute()
still has a type parameter. The type switch needs it for matching the type of s
against Producer[T]
. But the type parameter for Sizer
does not fool us anymore.
To be completely clear on this, these two variants of an interface are semantically identical. The type parameter has no effect at all.
type Doer interface {
Do()
}
type Doer[T any] interface {
Do()
}
Closely related to the above scenario is the following one. The generalized form of interfaces allows for modeling union types using generics. Here is an example of a union of string
and int
.
func PrintStringOrInt[T string | int](v T) {
switch v.(type) {
case string:
fmt.Printf("String: %s\n", v)
case int:
fmt.Printf("Int: %d\n", v)
default:
panic("Impossible")
}
}
func main() {
PrintStringOrInt("hello")
PrintStringOrInt(42)
}
Run this code in the Playground. The result might be somewhat unexpected.
The code does not even compile. The switch
statement triggers this error message:
./prog.go:10:9: cannot use type switch on type parameter value v (variable of type T constrained by StringOrInt)
Go build failed.
It looks like the type switch does not want to switch on a parametrized type.
This is in fact the result of an intentional decision of the Go team. It turned out that allowing type switches on parametrized types can cause confusion.
From the Type Parameters Proposal:
In an earlier version of this design, we permitted using type assertions and type switches on variables whose type was a type parameter, or whose type was based on a type parameter. We removed this facility because it is always possible to convert a value of any type to the empty interface type, and then use a type assertion or type switch on that. Also, it was sometimes confusing that in a constraint with a type set that uses approximation elements, a type assertion or type switch would use the actual type argument, not the underlying type of the type argument (the difference is explained in the section on identifying the matched predeclared type).
(emphasis mine)
Let me turn the emphasized statement into code. If the type constraint uses type approximation (note the tildes)...
func PrintStringOrInt[T ~string | ~int](v T)
...and if there also was a custom type with int
as the underlying type...
type Seconds int
...and if PrintOrString()
is called with a Seconds
parameter...
PrintStringOrInt(Seconds(42))
...then the switch
block would not enter the int
case but go right into the default
case, because Seconds
is not an int
. Developers might expect that case int:
matches the type Seconds
as well.
To allow a case
statement to match both Seconds
and int
would require a new syntax, like, for example,
case ~int:
As of this writing, the discussion is still open, and maybe it will result in an entirely new option for switching on a type parameter (such as, switch type T
).
See proposal: spec: generics: type switch on parametric types · Issue #45380 for details.
Luckily, we do not need to wait for this proposal to get implemented in a future release. There is a super simple workaround available right now.
Instead of switching on v.(type)
, switch on any(v).(type)
.
switch any(v).(type) {
...
This trick converts v
into an empty interface{}
(a.k.a. any
), for which the switch happily does the type matching.
This article is based on a discussion in the golang-nuts mailing list, and I want to thank T Benschar for bringing up the topic, Ian Lance Taylor for providing the answer, and Brian Candler for sharing more explanations and the trick of using any(v)
.
(Photo by Brett Jordan on Unsplash)
Categories: : Generics, The Language