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.

CC @griesemer @findleyr

About this issue

  • Original URL
  • State: closed
  • Created a year ago
  • Reactions: 3
  • Comments: 23 (14 by maintainers)

Commits related to this issue

Most upvoted comments

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 allowing cmp.Or[io.Writer](nilFile, io.Discard) and cmp.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) and var c someconcrete; var i someiface, it really should force you to write either F(someiface(c), i) or F[someiface](c, i). Just promoting c to an interface automatically is a little too surprising because you get different inferred types for F(c, c) and F(c, i).

I think this may be more of an issue with cmp.Or than with type inference proper.

The cmp.Or() case is an extension of the long-standing confusion around non-nil interfaces wrapping a nil value. The inference from the second argument causes the first to have different behavior. For example,

func IsFirstZero[T any](v1, v2 T) bool {
  var zero T
  return v1 == zero
}

func main() {
  fmt.Println(IsFirstZero((*os.File)(nil), os.Stdout)) // true
  fmt.Println(IsFirstZero((*os.File)(nil), rand.Reader) // false
}

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 way nil 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

var set mySet = toSet[io.Writer](os.Stdout, os.Stdin, io.Discard, os.Stdout)

@carlmjohnson In your comment, note that any is not a named (= defined) type, it’s an alias for interface{}. In that case, it doesn’t get selected over a previously inferred unnamed type that unifies with any. 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)) and F(io.ReadWriter(nil), io.Writer(nil)) infer different type arguments: https://go.dev/play/p/JhwnFnwxOeA?v=gotip

Is 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:

cannot use io.Discard (variable of type io.Writer) as *os.File value in argument to F: need type assertion

?

Analysis:

In the first case (F(nilFile, io.Discard)), we have an unnamed type (*os.File) and a named type (io.Writer, type of io.Discard). One of them (it doesn’t matter which one), is inferred as the initial “guess” for the type parameter T. Now the 2nd of the two types must match with T. That is, *os.File and io.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) and interface{ Write(p []byte) (n int, err error) } (underlying type of io.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 and io.Writer succeeds.

Now, because io.Writer is a named type, we record io.Writer as the type argument for T (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.