Wandering Thoughts archives

2021-03-29

Nil in Go is typed in theory and sort of untyped in practice

I was recently reading Down the Golang nil Rabbit Hole (via), and ran into the statement part way through:

So here’s a fun fact, Go has multiple “types” of nil. Specifically, there are both typed and untyped nil variables.

Some of you reading this are now reaching for your keyboards (cf), and that was my first reaction too. But I think it's more interesting to talk about how someone could wind up feeling that nil is sometimes untyped, because Go's nil is a little bit slippery when you combine it with other features and a necessary API for some things. So let's start with what nil is in Go and how it behaves in some circumstances.

In code, a literal of 'nil' effectively works as if it was an untyped constant like '0' (although it's not a constant; it's a predeclared identifier with special semantics that are sprinkled all through the specification). However, just like untyped constants, when you use nil by assigning it to a variable or passing it as a function argument, the resulting Go value is always typed, whether it is a pointer, function, slice, map, channel, or interface of some type. For concrete types that can be nil (pointers, functions, slices, maps, and channels), the type of even these nil values is readily observed. The fmt package's %T verb will report it, for example, and you can examine this type through reflect. However this is not quite the case for interfaces.

Like most everything else in Go, interfaces are typed, including the famous empty interface, interface{}, and as a result so are interface values. An interface value that is nil still has a type. However, this interface type (of interface values) is very hard to observe dynamically (in code) because of another property of Go: when you convert one interface type to another interface type, the original interface type is lost. If an interface value is not nil, it has an underlying concrete type, which is preserved when the interface value is converted to another interface type (although the original interface type is lost). But if the interface value is nil, there's no underlying concrete type and so there's no other type information to be preserved; you can't tell a nil of one interface type from a nil of another interface type.

Now we get to the subtle Go trap. Neither fmt nor reflect have a magic exemption from the Go type system in order to accept arbitrary Go types. Instead, they must use the Go escape hatch of the empty interface, interface{}, which everything can be converted to. But this means that when you call fmt or reflect on an interface value, you're performing an interface conversion, and as a result you lose the type of the original interface value. For instance, suppose you write code like this:

func fred(e error) {
  fmt.Printf("%#v %T\n", e, e)
}

The error type is an interface type. When you call fmt.Printf, the error interface value is converted to the interface{} type, and fmt.Printf now has no access to the fact that e was an error; all it can report is the concrete underlying type, if there is one. If you call 'fred(nil)', fmt winds up being passed a nil value of type interface{} and can only shrug about what type that value originally was.

(By extension, the fmt '%T' verb can never tell you what interface type was being used. If you want to know that, you must pass a pointer to the interface value and then '%T' that, which will report things like '*error'. See this playground example, which demonstrates that this does preserve the interface type for a nil interface value, as you'd expect.)

In this sense, nil interface values are untyped, in that their type is very hard to observe dynamically and check in practice in ordinary Go code. Due to this, a lot of perfectly normal Go code will tell you that such values have no type (fmt's '%T' verb will report their type as '<nil>', for example).

(Perhaps their type should be reported as 'interface{}', but that ship has sailed and it wouldn't be all that much more useful in practice.)

The other way to put this is that anything that takes interface{} arguments can't distinguish between nil values of different interface types, and by extension also can't distinguish between called with a literal nil and being called with an interface value that happens to be nil (for example, because it's a value of (interface) type error and there was no error). Both fmt and reflect take interface{} arguments and so are limited by this. If you're not aware of this indistinguishability, things can look pretty puzzling (or you can get caught out by it).

(This is another facet of how sometimes a nil is not a nil, which is sufficiently common to have a FAQ about it.)

programming/GoNilIsTypedSortOf written at 22:20:53; Add Comment


Page tools: See As Normal.
Search:
Login: Password:
Atom Syndication: Recent Pages, Recent Comments.

This dinky wiki is brought to you by the Insane Hackers Guild, Python sub-branch.