Skip to content
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: permit using nil with type parameters #62487

Closed
ianlancetaylor opened this issue Sep 6, 2023 · 34 comments
Closed

proposal: spec: permit using nil with type parameters #62487

ianlancetaylor opened this issue Sep 6, 2023 · 34 comments
Labels
Milestone

Comments

@ianlancetaylor
Copy link
Contributor

This is an alternative to #61372. I mentioned it in #61372 (comment), and I'm here pulling it out into a separate proposal.

I propose that we permit using nil with type parameters. Specifically, I propose that we change the language such that

  1. we can assign nil to a variable whose type is a type parameter; it sets the variable to the zero value of its type;
  2. we can compare a value whose type is a type parameter to nil; this is permitted even if the type parameter is not comparable, and == reports whether the value is equal to the zero value of its type.

Background

#61372 aims to solve three problems:

  1. Referring to a zero value in generic code. Today people suggest *new(T), which [@rsc finds] embarrasingly clunky to explain to new users. This comes up fairly often, and we need something cleaner.

  2. Comparing to a zero value in generic code, even for non-comparable type parameters. This comes up less often, but it did just come up in cmp.Or (cmp: add Or #60204).

  3. Shortening error returns: return zero, err is nicer than return time.Time{}, err.

The solution in #61372 is to introduce a new builtin type zero that may be used with any type that does not already have a short name for the zero value.

This proposal addresses the first two points but not the third.


Rationale

I'm concerned that #61372 is overfitting to the current language and creating a solution that makes sense to experienced Go programmers but not to people learning the language for the first time. We already have three names for the zero value: 0, "", nil. Introducing a fourth name, zero, makes sense given where we are but seems confusing for people learning the language for the first time.

Extending the use of zero to all types, as originally proposed and as mentioned by @robpike at #61372 (comment), permits confusion when using a variable p of pointer type: we can write both p == zero and *p == zero, but they mean very different things (as mentioned by @rsc at #61372 (comment)).

Extending the use of nil to all types somewhat exacerbates the problem address by the FAQ in Why is my nil error value no equal to nil?, which has itself been reported as a bug many times despite the FAQ entry (#43643, #44102, #47551, #53768, #54229, #56930, #57292, #59521 among many others).

Extending nil to type parameters only, as proposed here, is imperfect, but it addresses the immediate needs, and to me it seems less imperfect than the other solutions.

@willfaught
Copy link
Contributor

Extending the use of zero to all types, as originally proposed and as mentioned by @robpike at #61372 (comment), permits confusion when using a variable p of pointer type: we can write both p == zero and *p == zero, but they mean very different things (as mentioned by @rsc at #61372 (comment)).

@ianlancetaylor This sort of confusion is already permitted in Go:

func F() func() *int

var _ = F() == nil
var _ = F()() == nil

Is your argument that enabling that was a mistake, and we should minimize the number of ways this sort of mistake can happen in the future?


Using nil in generic functions, and zero elsewhere, adds another non-orthogonal dimension to the language, increasing the language complexity between those dimensions geometrically. What is your argument for why that complexity is less than the complexity of using zero or nil everywhere?

@cespare
Copy link
Contributor

cespare commented Sep 6, 2023

Introducing a fourth name, zero, makes sense given where we are but seems confusing for people learning the language for the first time.

The proposed expansion of nil seems like it would be be even harder to explain to new Go programmers than nil already is. Now we'll be able to use nil for (for example) numeric things when they are type parameters.

Overall I don't think this proposal is an improvement over #61372 in terms of complexity, explainability, and general language coherence, but it is substantially less useful (because it doesn't change error returns).

@ianlancetaylor
Copy link
Contributor Author

@willfaught I agree that function calls can lead to confusion with nil, but fortunately functions that return functions are much less common than pointers.

Using nil in generic functions, and zero elsewhere, adds another non-orthogonal dimension to the language,

To be clear, I'm suggesting that we not add the predeclared identifier zero.

What is your argument for why that complexity is less than the complexity of using zero or nil everywhere?

People must already use nil with various kinds of types, notably interface types. Adding type parameters to that list of types does not seem to me to be a significant increase in complexity.

@ianlancetaylor
Copy link
Contributor Author

@cespare

The proposed expansion of nil seems like it would be be even harder to explain to new Go programmers than nil already is. Now we'll be able to use nil for (for example) numeric things when they are type parameters.

Yes.

Overall I don't think this proposal is an improvement over #61372 in terms of complexity, explainability, and general language coherence, but it is substantially less useful (because it doesn't change error returns).

The more I think about #61372 the more reluctant I am to explain why there are four different ways of writing the zero value.

@willfaught
Copy link
Contributor

To be clear, I'm suggesting that we not add the predeclared identifier zero.

Thanks. I missed that.

I agree that function calls can lead to confusion with nil, but fortunately functions that return functions are much less common than pointers.

The point applies to any operation and nil-able type:

var X chan map[int]int

var _ = X == nil
var _ = <-X == nil

var Y [][]int

var _ = Y == nil
var _ = Y[0] == nil

These are just as "confusing" as p == nil vs. *p == nil.

You can make the same kind of argument for any operation at all:

var Z int

var _ = Z == 0
var _ = Z + 1 == 0

What if you forget the + 1 operation? It's the same as forgetting the * or () or <- or [0] operations.

It seems to me the issue is what if the programmer writes wrong code that still type-checks, and I would argue that we already don't guard against 99.99% of those cases.

People must already use nil with various kinds of types, notably interface types. Adding type parameters to that list of types does not seem to me to be a significant increase in complexity.

Then why not add numbers and strings to that list, for the same reason?

@jimmyfrasche
Copy link
Member

The list of 3 problems can be reduced to 2:

  1. name the zero value
  2. compare against the zero value

returning zeroes is just a case of 1, though surely the most common.

I said nothing about generics because generics is not the only place these problems arise, though it's the most likely place this comes up now.

For the purposes of this argument we can divide code into three classes, which I hope are obvious enough

  1. regular
  2. generic
  3. generated

In regular code naming zero comes up in cases where you need to write time.Time{} all over the place. Comparing against zero comes up when you have a struct containing a func so you end up having to write s.n == 0 && s.f == nil.

In generic code naming zero is much harder and comparing only works when the parameter is comparable. I think everyone gets why this is worse and why it means we should do something.

In generated code you have the same naming problem as with generic code and end up writing *new(T) and var zero T. Even if you have the type information available it's just simpler to do that than emit different code depending on the type. Even under #61372 with restrictions this would still be the case. Comparison is about the same as generic code. It's too much work to handle types that aren't comparable even if there are some that can be == nil. Under #61372 with restrictions there would need to be a check to see if you can use == zero or == *new(T).

If this proposal is accepted, generic code is fixed. It would do nothing for naming in regular or generic code but it would allow definition of

func isZero[T any](v T) bool {
  return v == nil
}

which could be used in regular/generated code to handle comparison.

But it would need to be copied around all over the place and there would still be the other problems. The only way to extend it would be to generalize nil to be the universal zero value.

This proposal would be an improvement but not a very satisfying one.

@DeedleFake
Copy link

DeedleFake commented Sep 6, 2023

If this proposal is accepted, I think that an IsZero[T any](T) bool function should probably be added to cmp. The Is prefix might not be necessary.

@ianlancetaylor
Copy link
Contributor Author

@willfaught

The point applies to any operation and nil-able type:

Understood. And yet I think that people write if p == nil far more often with pointer types. I don't think we need to belabor this point. We don't agree, and that's OK.

Then why not add numbers and strings to that list, for the same reason?

Because we don't need to. There is a problem to solve for type parameters. There is no problem with numbers and strings.

@willfaught
Copy link
Contributor

willfaught commented Sep 7, 2023

Understood. And yet I think that people write if p == nil far more often with pointer types. I don't think we need to belabor this point.

@ianlancetaylor What does the frequency of one vs. the other have to do with your argument that adding nil to every type would add more confusion to the language? My argument was that these "confusions" already exist in the language.

We don't agree, and that's OK.

This is not persuasive, and frankly comes across as a brush off. If you think you've identified a root cause of disagreement that can't be resolved, then explain your reasoning.

Because we don't need to. There is a problem to solve for type parameters. There is no problem with numbers and strings.

This is only restating the proposal, that zero values outside of generic contexts shouldn't be addressed. I'm trying to better understand the trade-offs of this approach vs. #61372 in terms of complexity, which I assume you think is a relevant aspect to consider.

@atdiar
Copy link

atdiar commented Sep 7, 2023

@ianlancetaylor personally I'm sympathetic to this idea. But I'm leaning more toward zero because I don't really see a type parameter as an interface but as a placeholder which has nilable types in its typeset sometimes but not always.

I think that overloading nil here would be equally confusing.

The rule that the zero value for a type parameter is nil seems assuredly easy to understand, I agree.

I think that zero should be the universal zero with some caveats in where it can be used (only assignments in order to zero out memory locations).
In that case zero isn't a fourth way to write zero values, it simply supersedes all the other ways for assignments. (but not function calls which is value passing)

Maybe that should be its own proposal I'm not sure.

@ianlancetaylor
Copy link
Contributor Author

@willfaught

What I said was "Extending the use of zero to all types ... permits confusion when using a variable p of pointer type: we can write both p == zero and *p == zero, but they mean very different things." What I mean is: people can intend to write one but accidentally write the other. Today that can't happen. I don't mean that this is confusing in the sense of understanding what it means. I mean that people can make a mistake, and the compiler will no longer help them. For that kind of argument, it does matter that people compare pointers to nil far more often than they compare other types to nil. I apologize for my lack of clarity.

@Merovius
Copy link
Contributor

Merovius commented Sep 7, 2023

@jimmyfrasche As an addendum, you can solve the naming problem with a function as well:

func zero[T any]() T {
    var z T
    return z
}

Right now, this doesn't help a lot for regular code (writing zero[time.Time]() isn't any better than time.Time{}) but if we add type-inference from assignment context (as I believe we should, at some point), you could just write return zero(), err. Even right now, it does help for generated code.

Of course, this has the same tradeoffs as isZero.

@jimmyfrasche
Copy link
Member

@Merovius At that point why not just have zero() and isZero() builtins which was rejected in favor of #61372? (very much 👍 on the inference, though!)

@griesemer
Copy link
Contributor

I'll just add to this discussion that as written in the spec, the underlying type of a type parameter is its constraint, which is an interface (which is the RHS of a type parameter declaration, matching other type declarations). Thus, a type parameter can be seen as an interface, albeit with a dynamic type that is "fixed" at instantiation time (this could also be a workable if basic implementation of generics). Being able to assign nil to a type parameter would be in sync with the ability to assign nil to ordinary interfaces. The assignment would only clear the value, not the type (as that one is fixed). So there's some technical merit to this idea.

Implementation-wise, if nil where permitted with type parameters as proposed, it would simply require removing a restriction in the type checker (nil can be used with interfaces, but not with type parameter interfaces at the moment); so it would actually simplify the implementation.

@ianlancetaylor

This comment was marked as resolved.

@DmitriyMV
Copy link
Contributor

DmitriyMV commented Sep 12, 2023

I think that we should not do this because that means one could write code like

func someGenericFunc[T any](v *T) {
	// some code
	if v == nil || *v == nil {
		// do stuff
	}
	// other code
}

Until now you could be sure that if you compare something against nil, you are either working with "reference-like" types like slices and interfaces or you work with pointers. Seeing code like v == nil || *v == nil and remembering that comparing against nil works differently in generic functions would confuse even experienced Go developers.

I'm also worried that people will start use (and define) IsZero everywhere just for consistency sake, even if we add it to the standard library.

@AndrewHarrisSPU
Copy link

Implementation-wise, if nil where permitted with type parameters as proposed, it would simply require removing a restriction in the type checker (nil can be used with interfaces, but not with type parameter interfaces at the moment); so it would actually simplify the implementation.

Thanks for the update - I was wondering about this when skimming go.dev/cl/520336. It seems subtle whether to consider this as "new evidence" vis-a-vis #61372. If it is "new evidence" (I think it's worth keeping track of...) I'd expect there will be more to consider in further details, and in code that starts using zero.

#61372 revealed a wide range of preferences, which can be frustrating for open decision-making. I think Osterhout may have been precisely aware of Arrow's theorem here - ranked or unstable preferences can make consensus impossible. Considering limited options can be tolerated, though, so I think this proposal is useful even if the inertia is with #61372.

@griesemer
Copy link
Contributor

@DmitriyMV If we accept #61372 as is (use zero where we don't have other representations for the zero value) and later decide that we should use zero as the universal zero value (that can be used with any type), then we also will be able to write p == zero || *p == zero. Putting it differently, if we don't like this current proposal primarily because we don't want people to write p == nil || *p == nil, we should also not (in the future) extend zero to mean the universal zero value.

@earthboundkid
Copy link
Contributor

#22729 is still appealing to me, although it seems like it's dead at this point. It seems like if we did this, we would want to pair it with some version of #22729, so nil is the universal zero, but in regular, non-generic code, you would use a more specific zero like 0.0, "", or whatever we call nil-interface (I liked none) and nil-pointer (null or nilptr).

@DmitriyMV
Copy link
Contributor

DmitriyMV commented Sep 13, 2023

@griesemer

we should also not (in the future) extend zero to mean the universal zero value.

I support this. At least there should be a vet check that warns against doing that. But I'm more worried about two things with this proposal:

  • First is the fact that with this proposal will mean different semantics for nil in generic and non-generic code. Currently you can do non-generic -> generic code and backwards with just adding types or type-parameters. With this proposal, you will also have to look for nils.
  • Second thing, is that we should not restrict the "check for zero" to generic code and force people to write (or use) generics even in non-generic contexts for things like comparing struct to its zero value. And if we add some other universal zero value check (like builtin function) in the future, that will result in separate ways of doing thing that is currently tackled by spec: add untyped builtin zero #61372

@earthboundkid
Copy link
Contributor

  • First is the fact that with this proposal will mean different semantics for nil in generic and non-generic code. Currently you can do non-generic -> generic code and backwards with just adding types or type-parameters. With this proposal, you will also have to look for nils.

#61327 has the same problem. If you take generic code and copy paste it, you would need to change zero to whatever it should be.

@DmitriyMV
Copy link
Contributor

  • First is the fact that with this proposal will mean different semantics for nil in generic and non-generic code. Currently you can do non-generic -> generic code and backwards with just adding types or type-parameters. With this proposal, you will also have to look for nils.

#61327 has the same problem. If you take generic code and copy paste it, you would need to change zero to whatever it should be.

I don't think so - I'm pretty sure that current zero proposal will require you to use nil for *T or T *E in generic code. At least that's my current reading.

@earthboundkid
Copy link
Contributor

If you take func isZero[T any](v T) bool { return v == zero } and try to instantiate it as isZeroInt, isZeroSlice, etc. you will need to adjust zero to be the correct zero for that type.

@AndrewHarrisSPU
Copy link

These are legal (if clever) patterns in non-generic code, and can lead to compound t == nil || *t == nil statements:

func f(t *any, ...) ...

or

type fn func()
func (f *fn) g(...) ...

I guess I'm unsurprised that a compound nil check would also appear with *T or T *E. The appearance of the compound check says something accurate, even if reading it takes a beat. It's more clever than usual but that's double-edged, generics can be a way to writing something unusually clever just once.

@DmitriyMV
Copy link
Contributor

DmitriyMV commented Sep 13, 2023

These are legal (if clever) patterns in non-generic code, and can lead to compound t == nil || *t == nil statements:

func f(t *any, ...) ...

or

type fn func()
func (f *fn) g(...) ...

I guess I'm unsurprised that a compound nil check would also appear with *T or T *E. The appearance of the compound check says something accurate, even if reading it takes a beat. It's more clever than usual but that's double-edged, generics can be a way to writing something unusually clever just once.

In both of those cases double check for nil actually reports you something very important - you are working with "reference to a reference" type. So when you see this check you already know that probably something non trivial is going on.

With nil relaxed to all types in generic context, you will have to remember that in generics functions this check can carry a different meaning.

If you take func isZero[T any](v T) bool { return v == zero } and try to instantiate it as isZeroInt, isZeroSlice, etc. you will need to adjust zero to be the correct zero for that type.

@carlmjohnson point taken. Although with zero you can actually replace every instance of zero with concrete value using simple text replace tool. With nil you have to be aware of the context so it will be manual work.

But my main concern was mostly about the fact, that nil will mean different thing in generics. I mean, if you think about it, it's essentially half-way of relaxing nil for everyone. And Russ noted that we do not want to lose compiler ability for finding potential bugs when he argued against relaxing nil. Then why generic code should have different rules?

@ianlancetaylor
Copy link
Contributor Author

@DmitriyMV

I don't think so - I'm pretty sure that current zero proposal will require you to use nil for *T or T *E in generic code. At least that's my current reading.

That is true. It's a different case that fails.

func F[T int | string](v T) T {
	return v + zero // Permitted because T supports + but has no specific name for the zero value.
}

Copying that version of F to be non-generic requires rewriting zero to either 0 or "", as appropriate.

@DmitriyMV
Copy link
Contributor

That is true. It's a different case that fails.

func F[T int | string](v T) T {
	return v + zero // Permitted because T supports + but has no specific name for the zero value.
}

Copying that version of F to be non-generic requires rewriting zero to either 0 or "", as appropriate.

@ianlancetaylor

I agreed with that in my message above. But isn't this the same for this proposal?

func F[T int | string](v T) T {
   return v + T(nil) // or even v + nil
}

@ianlancetaylor
Copy link
Contributor Author

@DmitriyMV Sorry, I was replying to #62487 (comment) and I missed that in the later comment #62487 (comment) you agreed that both proposals have the drawback that manually instantiating generic code can require modifying the code. Sorry for the noise.

@AndrewHarrisSPU
Copy link

AndrewHarrisSPU commented Sep 16, 2023

@DmitriyMV

But my main concern was mostly about the fact, that nil will mean different thing in generics. I mean, if you think about it, it's essentially half-way of relaxing nil for everyone. And Russ noted that we do not want to lose compiler ability for finding potential bugs when he argued against relaxing nil. Then why generic code should have different rules?

I'm reading the objection here as: it's novel that an untyped nil could convert to the zero value of a non-reference type, depending on instantiation. This is true but I'm not sure the same tactics for bug avoidance follow - it's tighter, not more relaxed, to assume nil bugs if, prior to instantiation, the potential exists. For example, a set-of-methods constraint can always be instantiated by an interface type, and a conventionally nil value is always possible.

This isn't dispositive w/r/t the zero spelling of #61372 - some thinking has to be done with any spelling - but zero will also be more novel in generic usage than in non-generic usage.

@seebs
Copy link
Contributor

seebs commented Sep 22, 2023

I am deeply conflicted on this.

In favor: I really like the generalization of "this is the thing we use to zero everything".
Against: I really dislike losing the distinction of "nil is pointer-shaped".

So I simultaneously love this and hate it and I don't know which is winning.

@earthboundkid
Copy link
Contributor

For reference of anyone not following that thread in real time, #61372 has just been withdrawn.

@Merovius
Copy link
Contributor

@seebs nit: slices are not pointer-shaped.

@dominikh
Copy link
Member

@Merovius Slices are more pointer-shaped than interfaces containing non-pointer types.

@ianlancetaylor
Copy link
Contributor Author

Retracting this along with #61372. We can take another pass at this later.

@ianlancetaylor ianlancetaylor closed this as not planned Won't fix, can't repro, duplicate, stale Sep 22, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests