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: add a new type constraint (called zeroed) and allow it in type assertions #62626

Closed
atdiar opened this issue Sep 13, 2023 · 14 comments
Labels
Milestone

Comments

@atdiar
Copy link

atdiar commented Sep 13, 2023

This is yet another attempt to address the issue of comparing zero values, providing a shorthand for the creation of these zero values at the same time.
For context, see the previous proposals #61372 and #62487

Edit: see #62626 (comment) that addresses criticisms of the proposal as it stands and puts it more in line with the current language modulo some theoretical implementation details.

Proposal

Instead of an untyped builtin zero, the idea is to introduce a new type constraint that we will call zeroed,
This constraint would be similar to another already existing special constraint: comparable (in that it would be special).

Usable in type assertions, it should let us construct zero values from pre-existing typed variables as well as test whether a variable holds the zero value of its type.

var str string = "qwertyazertydvorak"

s := str.(zeroed)
z,ok := str.(zeroed)

if ok{
         //  z == ""
}

        // z =="qwertyazertydvorak"

That's it for the proposal.

Rationale

The goal is to avoid a builtin for which we'd have zero != zero which is already the confusing case for nil.
Although the zero value is a fundamental concept in Go as the zero-assignment value of every variable/field, there are already specific notations for each type of zero that can be encountered in a Go program.
Moreover, currenly, not all types are comparable to their zero value and I wonder if that might not be a breaking change to change that behavior somehow (interface value comparisons). In doubt, an alternative that tries to ignore the question...

An untyped zero in assignments is not a problem; neither difficult to explain nor illegible. See below:

// NewElement returns a new Element with no properties, no event or mutation handlers.
// Essentially an empty shell to be customized.
func NewElement(id string, doctype string) *Element {
	e := &Element{
		nil,
		nil,
		nil,
		nil,
		NewElements(),
		false,
		nil,
		false,
		"",
		id,
		doctype,
		NewPropertyStore(),
		NewMutationCallbacks(),
		NewEventListenerStore(),
		NewNativeEventUnlisteners(),
		NewElements(),
		"",
		newViewNodes(),
		newViewAccessNode(nil, ""),
		nil,
		nil,
		nil,
	}

        // ...
	

	return e
}

As opposed to what it would be if a universal zero was accepted: (which looks fine to me in terms of readability, what do you think?)

// NewElement returns a new Element with no properties, no event or mutation handlers.
// Essentially an empty shell to be customized.
func NewElement(id string, doctype string) *Element {
	e := &Element{
		zero,
		zero,
		zero,
		zero,
		NewElements(),
		zero,
		zero,
		zero,
		zero,
		id,
		doctype,
		NewPropertyStore(),
		NewMutationCallbacks(),
		NewEventListenerStore(),
		NewNativeEventUnlisteners(),
		NewElements(),
		zero,
		newViewNodes(),
		newViewAccessNode(nil, ""),
		zero,
		zero,
		zero,
	}

        // ...
	

	return e
}

The more problematic issue is function arguments: zero as a superseding concept to nil, removes too much information as only nil is capable of signaling an absence of value.
That would make difficult to accept a universal zero. Hence, the proposed restrictions of #61372 would be here to stay which might be a pity. Why going to such lengths if the above cannot be written and zero simply explained to a beginner?

And there is still the issue that in code, we'd have:

var a *int
var b string

// ... some code

a = zero
b = zero

// but a is not b at all

Perhaps it is better to have more apparent semantics then:

var a *int
var b string

// ... some code

a = a.(zeroed)
b = b.(zeroed)

Further technical considerations

It might have been obvious to some people already but these special type constraints act a bit different in type assertions, when compared to interface types:

  • the type of the variable is kept as-is. It does not become the type of the constraint like it would with a regular interface. Matter fact, these constraints could be considered typed in context, a bit like these untyped builtins.
    In fact, a constraint is a criterion upon which a type set is built, a predicate. It does not have to be a type per se.

In which case, a type constraint would be a superset of interface types: all interface types would be constraints but some constraints would simply not be regular interfaces. It is already the case nowadays.
Of course, this is just semantics. In implementation, these can remain special cases of interfaces with a type set.

For zero, the theory says that the type set would be constructed from subtypes of each and every type which would contain the zero value as a singleton (a type being a set of values). So {0, "", untyped nil, typed nils, nilIface? , struct{}{}, ...}

All that to say that it would require to repurpose type assertions for these special interfaces (zeroed, comparable, etc.)

Generics

There wouldn't be any issue with zero value comparisons. The comma,ok declination of the type assertion would be a language feature eschewing the need for reflect.IsZero.

Although it is important to note that it should actually be an orthogonal concern. If generics are simply the generalization of non-parametered go code, then there is no reason to actually compare to zero in generic code since it cannot be done for all types in non-generic code.

Quite likely that a constraint comparable | nilable would be more adequate instead of any.
Also, the type assertions mechanism we have exposed here enables us to not redefine comparison for types that were previously not comparable even to their respective zero value.
So regardless of the type constraint used, all else being equal, this would be less problematic in that respect.

E.g.

type noncomparable struct{
    v []string
}

As such, it doesn't give the zero value additional semantics at the language level, apart from being a value that is a default.
There are even times when the sensible default to use is not the zero value but another.

For instance, in removal from a slice operations, we may want to start the removal index at -1, outside of the bounds of the slice.

func (e *Elements) Remove(el *Element) *Elements {
	index := -1
	for k, element := range e.List {
		if element.ID == el.ID {
			index = k
			break
		}
	}
	if index >= 0 {
		copy(e.List[index:], e.List[index+1:])
                e.List = e.List[:len(e.List)-1]
	}
	return e
}

This really is a userland concern and the way a zero value is used should probably not leak into the language itself.

Final note

This is really a proposal that has been written out of curiosity for what people may think, especially in light of the possibility of generalized interfaces at some point in the future (what would type assertions be then? for instance, could ~T work as exposed above as well?)

@gopherbot gopherbot added this to the Proposal milestone Sep 13, 2023
@atdiar
Copy link
Author

atdiar commented Sep 13, 2023

@ianlancetaylor @griesemer @rsc another idea, just trying to see what may stick out of a discussion.

@griesemer
Copy link
Contributor

You are using the notation for type assertions: x.(T). If a type assertion succeeds in Go, the type of the expression x.(T) is T. However, that is not true in your proposal as you say yourself: str.(zeroed) doesn't have the type zeroed, it has the type string.

I also observe that zeroed is not a "constraint" (type constraints in Go are interfaces), but some new form of type/concept for which there is no equivalent in current Go. Note that zero is an untyped zero value, which - while new - is closely related to untyped nil, untyped 0, etc. and thus simply an additional value added to a well-know set of values, rather than an entirely new concept.

Also, it seems a bit cumbersome to have to say str.(zeroed) to get the zero value for str (or to test for zero-ness - it's not quite clear from the proposal).

Finally, your argument that in non-generic Go code it's not possible to compare against the zero value for a particular type is simply incorrect.

In summary, you are introducing a new interpretation for the existing, well-defined notion of a type assertion, and you introduce a new type/concept (zeroed) which is vaguely specified. It's a pretty big gun for a relatively small problem. I note that we already now can write var zero P; x = zero in generic code.

For all these reasons I believe this current proposal should not be further pursued. Thanks.

@atdiar
Copy link
Author

atdiar commented Sep 13, 2023

You are using the notation for type assertions: x.(T). If a type assertion succeeds in Go, the type of the expression x.(T) is T. However, that is not true in your proposal as you say yourself: str.(zeroed) doesn't have the type zeroed, it has the type string.

I also observe that zeroed is not a "constraint" (type constraints in Go are interfaces), but some new form of type/concept for which there is no equivalent in current Go. Note that zero is an untyped zero value, which - while new - is closely related to untyped nil, untyped 0, etc. and thus simply an additional value added to a well-know set of values, rather than an entirely new concept.

Well, isn't it more of an identifier than a value per se? It cannot be assigned in short variable declarations ofr instance.
And it has no type?

Also, it seems a bit cumbersome to have to say str.(zeroed) to get the zero value for str (or to test for zero-ness - it's not quite clear from the proposal).

In the comma,ok idiom, that would be a test for zero-ness. A type assertions would assert whether a constraint applies or not.
The same way it tests whether a variable's type satisfies an interface nowadays.

edit: ah. that part might be wrong. currently it tests whether a type implements an interface rather doesn'it, nowadays?
But the argument is the same, zeroed contains only the subtypes composed of zero values so the behavior is as expected.

Finally, your argument that in non-generic Go code it's not possible to compare against the zero value for a particular type is simply incorrect.

What of this then? https://go.dev/play/p/tYDivOlEvck
(from #61372 (comment))

In summary, you are introducing a new interpretation for the existing, well-defined notion of a type assertion, and you introduce a new type/concept (zeroed) which is vaguely specified. It's a pretty big gun for a relatively small problem. I note that we already now can write var zero P; x = zero in generic code.

It's true that it's possible to create zero that way in generic code, but it is not possible to test generically for zero.
I believe type assertions could be quite amenable to that.

@griesemer
Copy link
Contributor

Well, isn't it more of an identifier than a value per se? It cannot be assigned in short variable declarations ofr instance.
And it has no type?

Of course it's an identifier, but in Go every identifier (except for _) stands for something, be it a variable, type, constant, function, package, label, predeclared value, etc. You don't say what zeroed is.
nil also cannot be used in a short variable declaration and also has no type. The proposed zero is in the same vein.

In the comma,ok idiom, that would be a test for zero-ness. A type assertions would assert whether a constraint applies or not. The same way it tests whether a variable's type satisfies an interface nowadays.

I don't know what that means with respect to your zeroed. What constraint? Why does it matter?

What of this then? https://go.dev/play/p/tYDivOlEvck

Not all types are comparable, agreed. But we can test slices, funcs, channels, maps to nil even though they cannot be compared to other slices, funcs, channels, and maps, respectively. That is what I was referring to. Sorry for being imprecise.

It's true that it's possible to create zero that way in generic code, but it is not possible to test generically for zero.
I believe type assertions could be quite amenable to that.

It could, I guess, but as I pointed out before, its a completely different form of "type assertion" because it's not asserting a type at all, but testing a value. We should not take a mechanism (type assertions) with a well-defined meaning and overload it with some new meaning that has nothing to do with its original function, unless there's a really convincing reason for it, e.g., because there's no other choice. We have several clearer choices.

The obvious choice should be to use == as we do everywhere else. The next obvious choice would be a predicate such as isZero.

edit: ah. that part might be wrong. currently it tests whether a type implements an interface rather doesn'it, nowadays?
But the argument is the same, zeroed contains only the subtypes composed of zero values so the behavior is as expected.

No, a type assertion tests whether the dynamic type of an interface is a specific type. We don't have a notion of all "subtypes composed of zero values" in Go, so the behavior is definitely not "as expected".

I believe you are mixing wildly different concepts to get to something for which we already have two pretty good and simple proposals, one of which has been accepted in limited form.

As I said before, I don't think this proposal should be pursued further.
I will refrain from adding more to this discussion. Thanks.

@atdiar
Copy link
Author

atdiar commented Sep 13, 2023

I understand. Many thanks for your input.

I will still note that comparable is in the same vein, a special interface.
And was also considered for use in non generic go code. That would go along the same mechanism.

But I can understand the reticence as well. It requires the idea of subtypes, at least to define zeroed.

Fair enough.

If we wanted to keep it an interface, that would be possible but more cumbersome. That would have people write
v.(zeroed).(T) to get the zero value where T is the type of the variable v. Maybe v.(zeroed) can be considered a shorthand.

It reads fluent as the value of a zeroed variable.

Edit: why subtypes? It's from the theory. If a type is a set of values, then a value is a type as well. Just that it is a singleton. Hence, zeroed would be the interface whose type set is the set of all zeroes that can exist. Can be an interface then.

@atdiar
Copy link
Author

atdiar commented Sep 16, 2023

Mmh. Doesn't fully work, although it should with amendments.

Explanation

Even if we had subtypes and subtypes were allowed in assertions, given a subtype S of type T,
v.(S) would panic if the value of v is not in S.

So let's say S is the subtype whose values is all zeroes, it would just have to panic unless v already holds a zero value. Not very interesting.

The comma, ok idiom would work though to test whether something is a zero.

I was initially trying to have a way to create zero values easily from other variables (although that's not necessary).

So the important bit is really about:
z, ok := v.(zeroed)

The constraint could also be spelled iszero or something else.

Conclusion

The proposal as it is, isn't clear enough.
The theory is there and my hunch is that it should be workable.

It would just test both type implementation (which is identity for a non-interface type) and value membership.

A subtype is basically a pair composed of a type identifier (package path + name?) and a subset of values of any type that is in the corresponding type set.

We could reuse type assertions as they are currently known.

A type being similar to an interface (it is actually an interface really) but more restrictive i.e. with additional constraints of name, shape etc.

@apparentlymart
Copy link

apparentlymart commented Sep 17, 2023

Reading the proposal leaves me with the understanding that str.(zeroed) is essentially the same as zeroed(str) if zeroed were defined like this:

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

Putting aside the oddity of using a value to represent a type -- an oddity that has plenty of precedent in Go already, from pre-generics -- this is already possible to define and use in Go today.

The variant with a second return value is not possible to implement in Go today, because it encounters part of the problem statement of #61372: not all types are comparable to their zero value, and so it isn't valid to compare a T any to its own zero value:

func zeroed[T any](v T) (T, bool) {
    var zero T
    // invalid operation: v == zero (incomparable types in type set)
    return v, v == zero
}

It seems to me like the smallest possible language change that would allow expressing this is to specify that any type can be compared to its own zero value, in which case it returns true only if the other operand is also the zero value of the type. If that were true then the rest of this proposal would presumably become just library code, rather than a specialized new language feature.


You did mention in your proposal that it might be a breaking change to make all types comparable to their zero value. I assume you are referring to the fact that today comparison of interface values will panic at runtime if the dynamic values are of the same type but not comparable:

type Uncomparable struct {
	F []string
}

func main() {
	u1, u2 := Uncomparable{}, Uncomparable{}
	i1, i2 := any(u1), any(u2)
	if i1 == i2 { // panics here
		fmt.Printf("success")
	}
}

It is true that making this succeed at runtime could change the behavior of some programs. It does seem plausible to treat interface value comparison differently here -- neither i1 nor i2 are the zero value of any and so this is not a direct comparison of two zero values -- but I'd agree that such an inconsistency would be unfortunate. I wonder if anyone has researched how commonly programs depend on detecting a panic when comparing two interface values. (I have not, and do not have a corpus with which to do so.)

@atdiar
Copy link
Author

atdiar commented Sep 17, 2023

That a type assertion to zeroed would return the zero value was what I initially wanted but Robert made the astute remark that it wasn't compatible with current type assertion semantics.

So in fact, zeroed should simply be its own interface type, like comparable.

type assertions with the boolean return, as you aptly show, is not implementable generically by users nowadays unless:

  • either we introduce a nilable constraint, because only variables of comparable or nilable types are comparable to their zero value
  • or for all types, variables are made comparable to their zero value (backward incompatible)

Note that the latter option is actually different from Russ's proposal in that it is proposed there that all typed variables should be comparable to a very specific identifier of zero values called zero.
In all other cases where the zero value is not identified by zero, regular comparison rules would apply. Incomparability to regular zero values would remain where relevant.
(understood that just now as per late comments on the untyped zero issue)
In this case, there shouldn't be any backward compatibility issue but then, I'm still not too sure if that's better than introducing a special interface and using type assertions.

It's true that in current Go, the value-subtype equivalence does not exist (yet?). But this wouldn't be too difficult to explain or understand.
This is simply the lower order equivalent to the relationship between interface types and non-interface types.

"All positive int values form a type called a subtype of int." is easy to understand.

Same way, zeroed, as the set of all zero values, is obviously an interface type and can probably be implemented in the compiler as a checkable predicate (if we check the type of a variable v in type assertions, it should be possible to AND isZero(v) to the result?, predicates on type AND value?)

Reusing the comma, ok idiom with type assertions would be fine then and there wouldn't be a need to introduce an untyped zero.

Also requires to extend type assertions to non-interface values.

@Merovius
Copy link
Contributor

Merovius commented Sep 21, 2023

The reason given not to add an iszero predicate builtin was:

to me that's a non-starter. We already have a way to test for equality to a zero value for many types, and that's == 0 or == nil. To fit into the existing Go language, any solution here should look like those.

This proposal clearly suffers from the same problem (but significantly worse).

@apparentlymart

It seems to me like the smallest possible language change that would allow expressing this is to specify that any type can be compared to its own zero value

There is a reason the language currently doesn't say "function types are comparable to their zero value", but says "function types are comparable to the predeclared identifier nil" (and why, analogously, #61372 allows comparing any type "to the predeclared identifier zero"). It is precisely to avoid the issue of having to specify cases in which the compiler can prove that a given value is the zero value.

That is, to make your example

func zeroed[T any](v T) (T, bool) {
    var zero T
    // invalid operation: v == zero (incomparable types in type set)
    return v, v == zero
}

work, we'd either have to 1. always allow the comparison and panic at runtime if one of the operands is not zero (thus doing away with the entire idea of comparable types), or 2. somehow specify what makes it obvious that zero is indeed the zero value in this case. Instead, we side-step the issue by only allowing comparison to the predeclared identifier nil (or zero) directly.

This, FTR, is also why the backwards-compatibility issue vaguely alluded to in the top-post does not actually exist. As has been pointed out to @atdiar a couple of times, by now.

@atdiar
Copy link
Author

atdiar commented Sep 21, 2023

The reason given not to add an iszero predicate builtin was:

to me that's a non-starter. We already have a way to test for equality to a zero value for many types, and that's == 0 or == nil. To fit into the existing Go language, any solution here should look like those.

This proposal clearly suffers from the same problem (but significantly worse).

Note that the reason is then a bit confusing (I was confused myself, it's not obvious). Comparing to 0 or nil is different because these are the zero values for some types as per spec.

Comparing to zero is not actually comparing to a zero value. It's comparing to a specially identified zero value which makes it a special case.

Overloading the == notation and creating a new notation ==zero for what is an assertion is confusing wrt what a zero value comparison is.
Making it an assertion via another mechanisms should be clearer.

Then again I think I understand how it works now but I think that ==zero erases too much information.

@apparentlymart

It seems to me like the smallest possible language change that would allow expressing this is to specify that any type can be compared to its own zero value

There is a reason the language currently doesn't say "function types are comparable to their zero value", but says "function types are comparable to the predeclared identifier nil" (and why, analogously, #61372 allows comparing any type "to the predeclared identifier zero"). It is precisely to avoid the issue of having to specify cases in which the compiler can prove that a given value is the zero value.

That means that such an assertion (isZero) should be dynamic, checked at runtime. That's more in favor of a predicate if we don't want a full blown subtype - type assertion.

This, FTR, is also why the backwards-compatibility issue vaguely alluded to in the top-post does not actually exist. As has been pointed out to @atdiar a couple of times, by now.

@Merovius the backward compatibility non-issue had been acknowledged as per the previous comment.

@Merovius
Copy link
Contributor

Merovius commented Sep 21, 2023

The proposal, AIUI, is to add zeroed as a type of values that can have "any value that is the zero value of its type". To have all our assumptions spelled out, here is what I can come up with to give that consistent meaning within the language as it exists today:

  1. Kind: zeroed is an unbounded sum (it can take arbitrarily many values of arbitrary memory layout) and so it would make sense to say it is an interface value. It would also have a dynamic type and a dynamic value, as existing interfaces.
  2. Zero value: Consequently, zeroed zero value should be nil - that is, what the zero value of an interface is.
  3. Embedding: Embedding zeroed in other interfaces would restrict them as expected - interface{ zeroed; io.Reader } would be occupied by the zero values of all types implementing io.Reader and so forth.
  4. Union elements: Similar to comparable, we would probably have to disallow using zeroed or any interface embedding zeroed in a union element. The reasons are the same: The open sum nature of zeroed means that type-checking such unions would likely become NP-complete.
  5. Assignability 1: As zeroed can only take values which are the zero value of their type, we can only assign things to it that are guaranteed to be the zero value of their type. That is, we can assign
    a) variables of type zeroed (or interfaces embedding zeroed)
    b) type-parameter typed variables if the type-parameter is constrained on zeroed
    c) nil (its own zero value)
    d) constants that are zero (0, 0.0, "", 0i,… etc, as well as constant-expressions that evaluate to one of those values)
    e) (potentially) composite-literals representing the zero value of their type (only applicable for struct and array literals)
    f) (potentially) variables of a type which only have one value, which is their zero value (i.e. all size 0 types as well as perhaps structs with only _ fields)
  6. Assignability 2: zeroed itself would not be assignable to any type except any and zeroed. An interface type expressible as interface{ zeroed; E } is assignable to itself, zeroed and E.
  7. Comparability: zeroed would not be comparable (as not all zero values are comparable). Alternatively, we could define zeroed to be comparable to anything that is assignable to zeroed. The comparison would be false, if the operands have different dynamic types and true otherwise (as the only possible dynamic value is be the zero value of the dynamic type).
  8. Type-assertions 1: If x is of type zeroed (or an interface embedding zeroed), then x.(T) should assert that the dynamic type of x is T. If so, it should evaluate to the zero value of T and if not, it should panic. y, ok := x.(T) and switch x.(type) should work in the obviously analogous way.
  9. Type-assertions 2: This is the meat of the proposal. There are two aspects to this:
    a) If x is an interface value, y, ok := x.(zeroed) should assign 1. nil, true, if x is nil (as nil is the zero value of its static type), 2. x, true, if the dynamic value of x is the zero value of its dynamic type (a successful interface type-assertion does not change dynamic values) and 3. nil, false otherwise. y would have type zeroed. That is how interface type-assertions work currently, so we should stay consistent.
    b) If x is not an interface type, x.(zeroed) would not be allowed. That is the consistent choice with the current language, as type-assertions are only allowed on interface types. We don't really have to allow this either, because you could always write any(x).(zeroed). So the minimalist solution would be to simply not allow it. The proposal is allowing them, but for the sake of discussion, we can treat that as a backwards-compatible extension of adding zeroed without it at first (see below).

This seems like a pretty exhaustive list of the sets of behaviors we need to define to add zeroed to the language as a type.

I think one interesting observation is, that this really makes zeroed a representation of a type. Its dynamic value carries no information, so it could be entirely left out and its representation could simply be the type-pointer we use in interface-headers. Comparing zeroed values is equivalent to checking if they have the same type. map[zeroed]T would be usable for similar purposes as map[reflect.Type]T is currently. The difference to reflect.Type is, that zeroed doesn't give any actual reflection information.

This is definitely an interesting property (Rust has a type like this and calls it TypeId), in and off itself.

The other things it allows is testing if a variable is the zero value of its type, i.e.

func IsZero[T any](v T) bool {
    _, ok := any(v).(zeroed)
    return ok
}

It doesn't add any power to construct zero values, though. Because if we can already construct a zero value of any static type by calling

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

What we can not do, currently, is construct the zero value of an interface values dynamic type. That is, given an io.Reader, we can't construct "the zero value of whatever concrete Reader implementation is contained". What I described above doesn't allow that either - a type-assertion will never change the dynamic value of what you put in, so it can't make something into a zero value that hasn't already been one.

In general, it can not be assumed that the zero value of an interface-implementation is safe to use (nor is it safe to assume that it is not safe to use). So I'm not sure how useful this would be, except insofar as it makes above observation that zeroed is essentially a type-identifier more useful (e.g. it would allow us to index into a map[zeroed]T from any interface value).


As I understand it, the actual proposal does try to fill this feature hole. Concretely, It seems to change the way type-assertions work from what I describe above, namely:

If x is an interface value, y, ok := x.(zeroed) should assign 1. nil, true, if x is nil (as nil is the zero value of its static type), 2. x, true, if the dynamic value of x is the zero value of its dynamic type (a successful interface type-assertion does not change dynamic values) and 3. zero, false otherwise, where zero is the zero-value of the dynamic value of x. y would have type zeroed.

The change is in that last case: It would assign a non-nil zeroed even if the type-assertion fails. That would be a first. Currently, all ,ok assignments will set y to the zero value of its static type, if they fail. With this change, it would not do that.

This is a major break in consistency with existing interface type-assertions. The equivalent would be if y, _ := any(new(bufio.Reader)).(io.Writer) would assign io.Writer((*bufio.Reader)(nil)) to y - it doesn't even make sense to talk about.

And it would do so for dubious value. So I definitely don't think this would be a road worth going down.


We can then discuss another extension of this zeroed-type: Allowing to write x.(zeroed) even if x is not an interface-type. I believe the only sensible semantic for this would be to make it equivalent to any(x).(zeroed). That would make the "check if a value is the zero value of its type" slightly more convenient (by allowing you to omit the any conversion), but doesn't add any power.

It also begs the question of what makes zeroed special. ISTM if we would go down this road, we should simply allow x.(T) to be equivalent to any(x).(T) for all interface-types T and non-interface values x (except in cases where the static type of x would prevent it from implementing T).

I don't know why we would do this, though. For non-generic code, we already know whether or not the type-assertion succeeds, based on the type of x (notably, it's not an interface type, so there is no dynamic type to compare). For generic code, #45380 seems a far more powerful and intuitive mechanism.


Anyways. This is a very long comment, but it's how I would really treat this proposal as a serious thought experiment.

I do think we could add zeroed as a type, consistently. It would in fact address the issue of allowing to check any type for its zero value - and it would have some other interesting implications (which are of dubious value, though).

But as @griesemer says, it is a very big hammer to use on this problem and the resulting mechanism is still pretty clunky, compared to #61372. It requires an extra statement and a temporary variable to check for zeroness (i.e. _, ok := x.(zeroed); if ok { … }, instead of if x == zero { … }). And I don't really see any advantages. The number of added predeclared identifiers is the same. And the zeroed type isn't really useful as a type apart from using it in type-assertions.

I don't think we should do this.

[edit] This ignores the previous comment, as I had already mostly typed out this pretty long response before it got added. I didn't want to throw it away and start over. So just take into account that I wasn't aware of it when writing this. [/edit]

@Merovius
Copy link
Contributor

Merovius commented Sep 21, 2023

Note that the reason is then a bit confusing (I was confused myself, it's not obvious). Comparing to 0 or nil is different because these are the zero values for some types as per spec.

Given that the quoted comment explicitly mentions == nil, it is not actually any different. As you are aware, by now.

That means that such an assertion (isZero) should be dynamic, checked at runtime. That's more in favor of a predicate if we don't want a full blown subtype - type assertion.

I do not understand how you can make this leap. Whether an int is 42 must be checked at runtime, yet we would never argue that this means x == 42 should be written as a type-assertion or predicate-function instead. This is a complete non-sequitur.

(To be clear: I'm not personally against adding a predeclared iszero predicate function or the like. Just like I'm not against adding a zero predeclared identifier. But given that we already know that iszero is rejected and for what reason, it makes sense to take that into account in the discussion)

@atdiar
Copy link
Author

atdiar commented Sep 21, 2023

I will comment some more later but re. the leap,
it's simply because checking whether a variable holds a zero value is different from checking whether the variable holds a given value.

The latter means that the variable is of a comparable type.

The former is checking a form of typestate of the variable that asserts that it is a zero, regardless of whether its type is comparable or not.

Introducing the zero identifier does that in a way. But I think it's not really necessary the best way to check for typestates. (subtyping i.e. using types, appears clearer to me, especially since we already have some form of type assertion mechanism in the language, matter of taste I guess).

Thanks for the in-depth observations in the post above. On a few minor points I would have some corrections but overall it's accurate.

Edit: the point 7 could be amended as zeroed, just like any could allow for comparisons. It would satisfy but not implement comparable (read enforce comparability of the members of its typeset).

@atdiar
Copy link
Author

atdiar commented Oct 4, 2023

Thanks for the discussion.
I will close the issue for now. If the idea needs to be revisited, I think it would be cleaner to write a proposal from a clean slate.

@atdiar atdiar closed this as not planned Won't fix, can't repro, duplicate, stale Oct 4, 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

5 participants