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: Go 2: a type set with a single type should act more like that type #66123

Open
2 of 4 tasks
jordan-bonecutter opened this issue Mar 5, 2024 · 13 comments
Open
2 of 4 tasks
Labels
generics Issue is related to generics LanguageChange Proposal v2 A language change or incompatible library change
Milestone

Comments

@jordan-bonecutter
Copy link

jordan-bonecutter commented Mar 5, 2024

Proposal Details

Go Programming Experience

Experienced

Other Languages Experience

JS, C, Python

Related Idea

  • Has this idea, or one like it, been proposed before?
  • Does this affect error handling?
  • Is this about generics?
  • Is this change backward compatible? Breaking the Go 1 compatibility guarantee is a large cost and requires a large benefit

Has this idea, or one like it, been proposed before?

No, not to my searching

Does this affect error handling?

No

Is this about generics?

Yes. This proposal allows adding methods on a subset of generic types.

Proposal

Consider the following code:

type Fooer interface {
  Foo()
}

func F1[T any, PT interface{
  Fooer
  *T
}](value PT) {
  ch := make(chan PT)
  F2(ch)
}

func F2[T any](ch chan *T) {}

Go complains with the following error:

cannot use ch (variable of type chan PT) as chan *T value in argument to F2

While this is not false (ch is of type chan PT), PT is a *T, according to our constraints. Therefore any generic type Type[PT] will be equal to Type[*T].

Formally, if any of the type parameters in a type constraint are concrete (non-interface) types, then any values adhering to that constraint may be considered as an instance of that concrete type.

The current workaround is to cast the value to any, and then downcast to the concrete type which is unnecessary and not intuitive.

Language Spec Changes

No changes to syntax, only recognizing that such a downcast is valid.

Informal Change

No response

Is this change backward compatible?

Yes, only more go programs are considered valid.

Orthogonality: How does this change interact or overlap with existing features?

The goal of this change is to improve the usability of generic utility types. Its success should be measured by its usage.

Would this change make Go easier or harder to learn, and why?

This doesn't increase the complexity of the language from a usability perspective.

Cost Description

Not every instance of a generic type will have the same method set, and the compiler will need to check this.

Changes to Go ToolChain

No response

Performance Costs

Compile time costs: negligible if not used. Run time cost: better than interface smuggling.

Prototype

No response

@gopherbot gopherbot added this to the Proposal milestone Mar 5, 2024
@atdiar
Copy link

atdiar commented Mar 5, 2024

F2 is underconstrained.
It doesn't know whether T is supposed to be a pointer type or a value type.
Said otherwise, it assumes the most general case since both kind of types are valid as type arguments.

I don't think there is much to do here. F2 needs to be constrained properly and the rest should be left to constraint type inference.

@jordan-bonecutter
Copy link
Author

jordan-bonecutter commented Mar 5, 2024

F2 is underconstrained. It doesn't know whether T is supposed to be a pointer type or a value type.

The type of T is irrelevant, but we know that the argument to F2 must be a pointer type (to what it points, we do not constrain.)

We can say the same about the argument to F1, that it must be concretized to a pointer type. Since the only thing we care about in F2 is that we pass in a pointer we should be able to pass in value (the argument to F1) as we know it is a pointer.

In fact, go knows that PT and *T are the same, we can convert between them:

type Fooer interface {
  Foo()
}

type Bar struct{}

func (*Bar) Foo() {}

func F1[T any, PT interface{
  Fooer
  *T
}](value PT) {
  F2((*T)(value))
}

func F2[T any](*T) {}

This code is valid, proving that values of PT are indeed *T. Maybe this is more an issue of covariance when it comes to generics. chan is covariant across its type which means that (at least in generic functions) a value of chan super should be assignable to a type of chan sub (given that sub is a tighter constraint than super.) This doesn't apply for non-generic code of course.

@atdiar
Copy link

atdiar commented Mar 6, 2024

Indeed. What I meant is that F2 doesn't know about chan PT. It is seen as a type argument as a whole.
The type of the function parameter chan *T doesn't constrain the type parameters in F2.
My suggestion was the below:
https://go.dev/play/p/E1QqQ9Tfky3

In other terms, chan PT is not really seen as a composite type argument by F2.
It's likely that it would be possible to infer constraints for composite types, in which case chan PT would be remembered as a chan[T, PT *T]->chan[PT]
(essentially, seeing chan as a kind of generic type constructor function)

@jordan-bonecutter
Copy link
Author

This is interesting.

To my understanding this extra constraint could be made unnecessary as the compiler has all the information required to understand that PT will meet the constraints for F2.

I should probably re-word the proposal to talk more about type covariance. The go FAQ explains, in a roundabout way in the Java generic section, why it doesn't handle covariance. I find this explanation somewhat unsatisfactory as the simplicity should be heavily weighted towards the code written in the language, not the language implementation (easy to say for me who doesn't work on the compiler 😬.)

@jordan-bonecutter jordan-bonecutter changed the title proposal: Go 2: allow downcasting value from type constraint proposal: Go 2: type inference for covariant types Mar 6, 2024
@jordan-bonecutter jordan-bonecutter changed the title proposal: Go 2: type inference for covariant types proposal: Go 2: better handling for generic covariant types Mar 6, 2024
@atdiar
Copy link

atdiar commented Mar 6, 2024

Hmmh, I think your initial title is a bit more accurate as I am not sure it is related to variance. Or at least, there is probably a better way to describe the issue.

I think that it is mostly the treatment of composite types that is still being worked upon.

@jordan-bonecutter
Copy link
Author

Covariance is how one would describe a composite type which follows the same sub-super rules no? (chan T is covariant across T, in this case PT is a subclass of *T therefore a tighter constraint and should be assignable.)

But agreed there's probably a better way to describe this, I just lack the knowledge :)

@atdiar
Copy link

atdiar commented Mar 6, 2024

I guess you're right. This seems to be some sort of type parameter covariance. Why not :)

@ianlancetaylor ianlancetaylor added the generics Issue is related to generics label Mar 6, 2024
@ianlancetaylor
Copy link
Contributor

We don't support covariance anywhere else in Go. See https://go.dev/doc/faq#covariant_types. I would be nervous about permitting it just for type parameter satisfaction. I would want to see a very compelling argument for why it makes it possible to write code that is currently difficult to write.

@ianlancetaylor ianlancetaylor added LanguageChange v2 A language change or incompatible library change labels Mar 6, 2024
@ianlancetaylor
Copy link
Contributor

Based on the discussion above, this is a likely decline. Leaving open for four weeks for final comments.

@Merovius
Copy link
Contributor

Merovius commented Mar 27, 2024

I think the issue is mistitled. This isn't about covariance.

First, chan has to be invariant, for the same reason as slices. We could make <-chan co- and chan<- contravariant, but chan needs to be invariant. But that's besides the point - it just clues us in that this isn't about variance.

One way to demonstrate that this is definitely somehow related to generics specifically, is to check if the code under consideration works if you manually monomorphize it. Which it does. So, this isn't just a case of doing something special for generic code: There is something about it not working that's because of generics.

Then the example by @jordan-bonecutter to compare with pointers seems unfortunate to me. In that example, a value of PT is converted (not just assigned) to the only value in its type set and it is PT itself, which is a type parameter, so it's a defined type with underlying type being an interface. In the example from the top post, it's not PT that is used, but chan PT (so a composite type that involves PT, which is no longer a defined type). And it is assigned, not just converted. These differences matter, because the spec treats these very differently.

In regards to the assignment it turns out the pointer code even works without the conversion (note: I renamed the type parameters, to make the comparison to the spec easier). This is due to the assignability rules for type-parameters:

Additionally, if x's type V or T are type parameters, x is assignable to a variable of type T if one of the following conditions applies:

  • x is the predeclared identifier nil, T is a type parameter, and x is assignable to each type in T's type set.
  • V is not a named type, T is a type parameter, and x is assignable to each type in T's type set.
  • V is a type parameter and T is not a named type, and values of each type in V's type set are assignable to T.

I think the last rule applies here: V is a type parameter PX, T is *X (not a named type) and the type set of V is {*X}, which is assignable to that.

This demonstrates that it's important whether or not we directly use a type-parameter or not. In the top-post example, V is chan PT, which is neither a type parameter, nor a named type and the type assigned to is chan *T, which likewise is neither.

I think a closer analogon to the top-post example, involving pointers, is this one:

type Fooer interface {
	Foo()
}

func F1[X any, PX interface {
	Fooer
	*X
}](value PX) {
	var p *PX // composite type involving PX
	F2[X](p) // cannot use p (variable of type *PX) as **X value in argument to F2[X]
}

func F2[T any](p **T) {}

This doesn't work and neither does a conversion.

So I think we can minimize the example somewhat:

func F[X any, PX *X]() {
	var (
		x []*X
		p []PX
	)
	x = p
	x = ([]*X)(p)
	_ = x
}

Or arguably even further:

func F[X int]() {
	var (
		x []int
		y []X
	)
	x = y
	x = ([]int)(y)
	_ = x
}

I'm using slices here, arbitrarily - it could be any composite type.

So the core complaint about this issue, AFAICT, is: If I have two composite types T1 and T2, where T2 involves a type-parameter that has exactly one type in its type set if I replaced that type parameter with the only type in its type set, T2 and T1 would be identical - should they then be assignable and/or convertible to each other?

I think this is losely related to @griesemer's #63940 as well, in the vague sense that this is about "well, the code is equivalent with the only type in the type set, so it really should be treated the same", which is what core types kinda do. Perhaps.

@ianlancetaylor I would make the case that while this is currently working as intended, there is something here, despite the incorrect attribution of the problem.

@ianlancetaylor
Copy link
Contributor

@Merovius Thanks for looking into this.

@ianlancetaylor ianlancetaylor changed the title proposal: Go 2: better handling for generic covariant types proposal: Go 2: a type set with a single type should act more like that type Apr 3, 2024
@ianlancetaylor
Copy link
Contributor

Undoing likely decline status. This needs further thought. Thanks.

@atdiar
Copy link

atdiar commented Apr 4, 2024

Paradoxically, I think that the initial title is fine too although this is not necessarily the common nomenclature.

This can be considered some sort of variance issue but restricted to type parameters (hence the "generic") but that's not too important.

I think PT is somehow in the type set that represents all pointer types and that info somehow gets lost once we deal with chan(PT) (compound types i.e. Types which don't have a core type will share that issue as was demonstrated above).

That's why chan(PT) can't be passed to F2 while a real instantiation doesn't error out.
Also why modifying F2 parametrization to recover the "pointer" type argument information works.

That might solve itself once the core type issue that was linked is processed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
generics Issue is related to generics LanguageChange Proposal v2 A language change or incompatible library change
Projects
None yet
Development

No branches or pull requests

5 participants