go: cmd/compile: unexpected type inference with interface types
Example program https://go.dev/play/p/rLW5osbRq6Z?v=gotip
package main
import (
"fmt"
"io"
"os"
)
func F[T any](v ...T) {
var zero T
fmt.Printf("%T\n", &zero)
}
func main() {
var nilFile *os.File
F(nilFile, io.Discard)
F(nilFile, os.Stdout)
}
With current HEAD running this program prints
*io.Writer
**os.File
The second result is reasonable. The first, less so. The compiler has taken a value of type *os.File
and a value of type io.Writer
, which must be of the same type, and inferred a type argument of io.Writer
. This seems potentially quite confusing, as pointed out at https://github.com/golang/go/issues/60204#issuecomment-1601820737.
Let’s make sure this is what we want before we commit it to 1.21.
About this issue
- Original URL
- State: closed
- Created a year ago
- Reactions: 3
- Comments: 23 (14 by maintainers)
Commits related to this issue
- go/types, types2: more readable inference trace Print the unification mode in human-readable form. Use a tab and // instead of ()'s to show unification mode and whether operands where swapped. These... — committed to golang/go by griesemer a year ago
- go/types, types2: more readable inference trace Print the unification mode in human-readable form. Use a tab and // instead of ()'s to show unification mode and whether operands where swapped. These... — committed to tailscale/go by griesemer a year ago
- go/types, types2: fix interface unification When unification of two types succeeds and at least one of them is an interface, we must be more cautious about when to accept the unification, to avoid or... — committed to tailscale/go by griesemer a year ago
Proposed solution:
Inference fails whenever we need to select a type from a list of types that includes interface and non-interface types (this addresses the issue at hand). (We can probably make this work correctly, but not for 1.21. By failing in this case we leave the door open to fixing it down the road.)
~If we have only interface types, the most general interface is selected, if there is one in the list that is implemented by all other interfaces that appear. (We don’t want to create interfaces that do not appear in the program.) This addresses @mdempsky 's comment.~
If we have only interface types, they must have the same methods. We cannot chose the most general one because that may introduce an order dependency since we also select named interfaces over unnamed ones.
If all interfaces are the same, and there are named interfaces, the named interfaces must all have the same name or inference fails. (If we have to chose a name, inference becomes parameter-order dependent. This would be visible when the type is printed.)
If a named interface exists (and all other named interfaces have the same name), the named interface is selected over unnamed interfaces with the same methods (but if the interfaces have different method sets, the most general one wins, see first rule above).
The actual change is very localized and small.
This should address this issue and also #60946, and is backward-compatible with 1.20.
I’d lean towards making it an error to implicitly convert from a concrete type to an inferred interface type. That would make
cmp.Or(nilFile, io.Discard)
into an error (e.g., “cannot assign nilFile (type *os.File) to inferred interface type io.Writer”), while still allowingcmp.Or[io.Writer](nilFile, io.Discard)
andcmp.Or(io.Writer(nilFile), io.Discard)
(not that I think either of these forms would be useful with cmp.Or).That seems like the minimal restriction to protect users from the mistake of accidentally converting zero-values of concrete type into non-zero-values of interface type, due to type inference.
I feel like if you have
func F[T any](a, b T)
andvar c someconcrete; var i someiface
, it really should force you to write eitherF(someiface(c), i)
orF[someiface](c, i)
. Just promotingc
to an interface automatically is a little too surprising because you get different inferred types forF(c, c)
andF(c, i)
.The
cmp.Or()
case is an extension of the long-standing confusion around non-nil
interfaces wrapping anil
value. The inference from the second argument causes the first to have different behavior. For example,Thanks to the inference to an interface type, the first is no longer a zero value when it was before. This can happen without any explicit typing, making it extremely confusing and hard to track down. At the very least I think it should be an error to infer an interface type from multiple sources where at least one is not an interface type. Then you’d have to do an explicit conversion, such as
IsFirstZero(io.Reader((*os.File)(nil)), rand.Reader)
. I think the only real alternative is to modify the waynil
is defined for interfaces, and that seams unreasonable.We probably should never infer an interface type unless all types that need to unify are the same (possibly named) interface. In all other cases, inferring an interface type amounts to finding a “super type” implemented by other, non-interface types; or finding the most general interface type among a set of interface types (which may lead to inferring an interface type that doesn’t exist in the program, or to
any
). Both these scenarios are problematic.If we follow this approach, we will get an error for the problematic
F(nilFile, io.Discard)
case.@DmitriyMV No, because io.Discard is an interface type. That is exactly the problem we need to address here. You will need to instantiate explicitly, as in
@carlmjohnson In your comment, note that
any
is not a named (= defined) type, it’s an alias forinterface{}
. In that case, it doesn’t get selected over a previously inferred unnamed type that unifies withany
. That explains the behavior; but I agree that this is wrong and needs to be fixed (it’s part of this very problem).@mdempsky Ok, thanks for the clarification. Inference ought to be independent of argument order. I think the problem you describe is related to #60946 but I will verify.
@griesemer As I understand your previous message,
F(io.Writer(nil), io.ReadWriter(nil))
would be an error, because io.Writer and io.ReadWriter are distinct interface types.I think making that an error is conservative and okay for 1.21.
But I’d argue that I think it’s okay to infer io.Writer as the type argument, because io.ReadWriter is still an interface type. So the implicit conversion from assigning the io.ReadWriter argument to the io.Writer parameter type can’t turn a zero value into a non-zero value. The error-prone zero-into-non-zero conversion only happens when turning a value of concrete type into an interface type.
–
Incidentally, I notice that
F(io.Writer(nil), io.ReadWriter(nil))
andF(io.ReadWriter(nil), io.Writer(nil))
infer different type arguments: https://go.dev/play/p/JhwnFnwxOeA?v=gotipIs that intentional/known? I thought inference was supposed to be independent of argument order.
Perhaps related to #60946.
Do we want an error in this case, such as:
?
Analysis:
In the first case (
F(nilFile, io.Discard)
), we have an unnamed type (*os.File
) and a named type (io.Writer
, type ofio.Discard
). One of them (it doesn’t matter which one), is inferred as the initial “guess” for the type parameterT
. Now the 2nd of the two types must match withT
. That is,*os.File
andio.Writer
must unify. Because we have a named and an unnamed type which are different, type inference tries to unify the underlying types, so*os.File
(unchanged) andinterface{ Write(p []byte) (n int, err error) }
(underlying type ofio.Writer
). Now we have the unification of an interface and a non-interface type. This succeeds if the non-interface type (*os.File
) implements the interface, which it does. Therefore unification of*os.File
andio.Writer
succeeds.Now, because
io.Writer
is a named type, we recordio.Writer
as the type argument forT
(if it’s not yet the inferred type argument). This is important in other (non-interface) situations: a named type with the same underlying type as an unnamed type unifies, so it’s safe to infer the named type. If we don’t do that, inference becomes function parameter order-dependent.Not yet sure what the right “fix” here is.