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

spec: investigate if we can remove (most or all) needs for the concept of a core type #63940

Open
griesemer opened this issue Nov 3, 2023 · 29 comments
Assignees
Labels
NeedsInvestigation Someone must examine and confirm this is a valid issue and not a duplicate of an existing one. umbrella
Milestone

Comments

@griesemer
Copy link
Contributor

The original generics proposal (#43651) and accompanying detailed design doc didn't specify the notion of a core type.

The spec introduced core types to determine whether an operation is permitted on a generic operand (i.e., an operand with a type containing a type parameter). Core types are also used to explain type inference involving constraints. This was mostly to have a somewhat manageable implementation and specification path from the code before generics to the code with generics, while not being too restrictive in practice.

On the other hand, the original proposal essentially just said that an operation is permitted if it is permitted for all types in the type set defined by the constraint. That is a more general approach and it may also be easier to understand (though not easier to implement).

We should investigate if we can avoid the use of core types more widely. We already do avoid them for some operations, such as indexing. If we can avoid them everywhere, we should be able to enable some desired features (such as #48522) w/o extra rules but as a matter of course.

Umbrella issue for changes related to this.

@griesemer griesemer added NeedsInvestigation Someone must examine and confirm this is a valid issue and not a duplicate of an existing one. umbrella labels Nov 3, 2023
@griesemer griesemer added this to the gopls/unplanned milestone Nov 3, 2023
@griesemer griesemer self-assigned this Nov 3, 2023
@griesemer
Copy link
Contributor Author

cc: @ianlancetaylor

@dmitshur
Copy link
Contributor

dmitshur commented Nov 3, 2023

Also CC @findleyr.

@findleyr findleyr modified the milestones: gopls/unplanned, Unplanned Nov 3, 2023
@findleyr
Copy link
Contributor

findleyr commented Nov 3, 2023

Thanks for filing this.

As a general rule, I think wherever we can avoid referring to core types the spec gets lighter and the generics implementation gets more useful. However, this benefit is not uniformly distributed. For example, while I think it would be very nice to remove restrictions on struct field access, it is less useful to avoid restrictions on e.g. range statements (do we really need to be able to range over []int|map[string]bool?).

So I think it is a good goal to avoid referencing core types. It would be quite an achievement to avoid all references, but that's probably not strictly necessary. (Also, if we can narrow it down to a just handful of references, perhaps we can phrase restrictions without needing to reference a "core type" abstraction).

@bcmills
Copy link
Contributor

bcmills commented Nov 6, 2023

@findleyr, I agree that not being able to range over []int|map[string]bool is not a big deal, but I think core types are problematic even for range statements.

For example, I would expect to be able to range over map[string]bool | map[string]struct{}, even though IIUC such a constraint does not have a core type today.

@findleyr
Copy link
Contributor

findleyr commented Nov 6, 2023

@bcmills I think that's an example of where some restrictions may still be required, but need not be expressed in terms of a "core type" abstraction. For example, we can insist that the key and/or value types be well-defined.

@piroux
Copy link

piroux commented Apr 12, 2024

Does the Go team is waiting to release other Generics features or any Core types refactor that should be part of this investigation at some point?
If not, could we add this investigation to a release milestone?

I am trying to understand if there is any current or short-term work that would be preventing this investigation to happen sooner, thanks.

@ianlancetaylor
Copy link
Contributor

We are all busy and must prioritize among many different issues. Assigning a release milestone won't affect how quickly we focus on this issue. What would matter instead is reasons in favor of, or against, working on this issue. Thanks.

@atdiar
Copy link

atdiar commented May 4, 2024

[edit: someone deleted their message about having access to struct fields if they were available to every type in the type set, I'm not talking alone, I promise ;)]
Yup and since struct fields are addressable, it should allow for some code that is currently not possible to write, when someone needs to access those field addresses in a generic function. (I at least have such use-case and no true workaround so it seems legitimate to me for now).

Re. Core types, I think they should probably be subsumed by another concept. At the implementation level, it's okay to keep them but I'm not sure that they are enough to explain all behaviors.

For instance and as noted by others above, if we want rangeability over a union of map types, it would be necessary and probably sufficient to attach a map-rangeable predicate to map types/constructors.

(there are different type of rangeability, slice-rangeable would be different, just as chan-rangeable would also be its own rangeability, etc...).
The difference is illustrated in the LHS of range statements.

If such a predicate/proposition holds for every member of a union interface, the common operation should be allowed in the body of the generic function.

That might require some addition to the types.Type struct (as a map of predicates/propositions?) amongst other things?

I think that's also closer to the initial idea described in the proposal.

@creativecreature
Copy link

I believe that this functionality would be really beneficial for anyone writing business logic in Go. I at least routinely revisit this issue to check on the progress.

There are plenty of good examples in this issue #48522, but to summarize, I think it boils down to the difficulty of writing code based on what something is rather than what it does. For example, adding getter/setter-style functions to an interface feels like a code smell because it's not really behavior it's state. Embedding for state feels wrong too, and sometimes you're not even allowed to modify the structs.

Therefore, I often find myself in situations where it feels like I'm stuck between a rock and a hard place: either I sprinkle my code with tiny interfaces that essentially just contain getter methods, or I duplicate parts of the functions for every struct. Regardless of the choice, I think a generic function that could access the fields of the structs without any indirection would greatly reduce the amount of code I have to write to achieve this, without making the code more complex or harder to read.

@Merovius
Copy link
Contributor

@creativecreature To be clear, this issue has, AIUI, zero impact on what kind of Go code you can write or how you would write it. AIUI this is simply about a spec change, to codify the same language, without having to refer to "core types".

@atdiar
Copy link

atdiar commented May 13, 2024

@Merovius

@creativecreature To be clear, this issue has, AIUI, zero impact on what kind of Go code you can write or how you would write it. AIUI this is simply about a spec change, to codify the same language, without having to refer to "core types".

Not really.

In the initial text of the proposal:

 If we can avoid them everywhere, we should be able to enable some desired features (such as #48522)

It's true however that this issue is not entirely specific to #48522 but #48522 shows at least one potential motivation to pursue it.

On the other hand, yes, it won't replace the need for getter/setter methods where they are relevant. There are many things that require to access state in a more elaborate way than simply mere field access. (can be locking a mutex before such access for example). That should be a given?

@creativecreature
Copy link

@Merovius It says that it's an umbrella issue, and the issue I was referring to was closed in favour of this: #48522 (comment)

@seancfoley
Copy link

seancfoley commented Jul 4, 2024

@creativecreature

Therefore, I often find myself in situations where it feels like I'm stuck between a rock and a hard place: either I sprinkle my code with tiny interfaces

Why is that a problem? You've admitted they are tiny. So what's the big deal? You want to complicate the language with syntax that adds no real capability to the language so you can avoid writing a tiny one-line interface. I don't see the point. The net gain is negative.

@creativecreature
Copy link

@seancfoley

Why is that a problem? You've admitted they are tiny. So what's the big deal? You want to complicate the language with syntax that adds no real capability to the language so you can avoid writing a tiny one-line interface. I don't see the point. The net gain is negative.

I think the net gain from adding generics to the language is negative if you can't do something like this:

type Point struct {
	X, Y int
}

type Rect struct {
	X, Y, W, H int
}

type Elli struct {
	X, Y, W, H int
}

func GetX[P interface { Point | Rect | Elli }] (p P) int {
	return p.X
}

Do you believe that to be more complex than this?

type Point struct {
	X, Y int
}

func (p Point) GetX() int {
	return p.X
}

type Rect struct {
	X, Y, W, H int
}

func (r Rect) GetX() int {
	return r.X
}

type Elli struct {
	X, Y, W, H int
}

func (e Elli) GetX() int {
	return e.X
}

type GetXer interface {
	GetX() int
}

func GetX(x GetXer) int {
	return x.GetX()
}

@jub0bs
Copy link

jub0bs commented Jul 4, 2024

@creativecreature What you're suggesting isn't as simple as it seems. For instance, would/should the following work?

type Point struct {
	Y, X int
}

type Rect struct {
	X, Z, Y, W, H int
}

type Elli struct {
	Y, W, H int
	Foo
}

type Foo struct {
	X int
}

func GetX[P interface { Point | Rect | Elli }] (p P) int {
	return p.X
}

Unclear.

@creativecreature
Copy link

@jub0bs

What you're suggesting isn't as simple as it seems. For instance, would/should the following work?

Hmm, what is the big difference compared to this in your opinion?

type Point struct {
	X, Y int
}

func (p Point) GetX() int {
	return p.X
}

type Rect struct {
	X, Y, W, H int
}

func (r Rect) GetX() int {
	return r.X
}

type Elli struct {
	Y, W, H int
	Foo
}

type Foo struct {
	X int
}

func (f Foo) GetX() int {
	return f.X
}

type GetXer interface {
	GetX() int
}

func GetX(x GetXer) int {
	return x.GetX()
}

func main() {
	e := Elli{1, 2, 3, Foo{4}}
	GetX(e)
}

@jub0bs
Copy link

jub0bs commented Jul 4, 2024

@creativecreature Isn't this the same code snippet as in your earlier comment? I don't think we're going to make much progress in this discussion if you answer my simple question by another question. 😅

Allow me to clarify. I'm not sure what interface { Point | Rect | Elli } means or should mean. In particular,

  1. Does the order of the fields in the various struct declarations matter? Or should field X be first in all those declarations?

  2. What if field X comes from an embedded struct? Does that count?

    type Elli struct {
    	Y, W, H int
    	Foo
    }
    
    type Foo struct {
    	X int
    }

@atdiar
Copy link

atdiar commented Jul 4, 2024

@jub0bs would probably fail to compile sunce we have Elli.Foo.X and not Elli.X
Fields are not promoted I think.

@jub0bs
Copy link

jub0bs commented Jul 4, 2024

@atdiar Both methods and fields get promoted from the embedded type to the outer struct; see https://go.dev/play/p/64XgN0ucshE

@atdiar
Copy link

atdiar commented Jul 4, 2024

@atdiar Both methods and fields get promoted to the embedded type to the outer struct; see https://go.dev/play/p/64XgN0ucshE

Woop. I stand corrected. :)

Edit: given that the following doesn't compile https://go.dev/play/p/8aUrIUFCI9K

Then I'd guess the initial example should actually be valid.
Embedding provides new fields.

But then what about https://go.dev/play/p/RQPN9zYVdPt

Unclear indeed. (we could perhaps follow the non-generic rules exemplified here https://go.dev/play/p/OQC_2dkml1Z)

@creativecreature
Copy link

creativecreature commented Jul 4, 2024

@jub0bs

Allow me to clarify. I'm not sure what interface { Point | Rect | Elli } means or should mean

It declares P as a type parameter with a type constraint. This is already in the language.

Therefore, I don't think I understand the issue with embedding. It should just work the same? E.g I would not expect this function:

func GetX[P interface{ Point | Rect | Elli }](p P) int {
	return p.X
}

to be any different compared to this:

func GetPointX(p Point) int {
	return p.X
}

func GetRectX(r Rect) int {
	return r.X
}

func GetElliX(e Elli) int {
	return e.X
}

@seancfoley
Copy link

seancfoley commented Jul 4, 2024

@creativecreature

What would happen here?

type Point struct {
	Y, X int64
}

type Rect struct {
	X, Z, Y, W, H uint
}

type Elli struct {
	Y, W, H int
	Foo
}

type Foo struct {
	X int
}

func GetX[P interface { Point | Rect | Elli }] (p P) int {
	return p.X
}

That's when it starts to get more complicated than what we have today. Today, using interface methods, we can say what we want to happen in this case. But with your proposal, if the field names do match up exactly, or if the types do not match up exactly, then the language construct is useless, and there is no way to reconcile this. You might make some random rule that in such cases you end up with int64 as being the only possible return type allowed for GetX, but then some other person might say they need the rule to be changed so that it is uint64. But some might wish that it be int, in which case you introduce new bugs where integers are being truncated unknowingly (since you have to examine each type closely to know).

Either you must construct random rules that do not work for everyone, or you disallow the use of this new language construct in even the most simple cases where it should be useful.

And then suppose in my code I have

type Box struct {
	X1, X2 float
}

But I cannot combine my type with your types when using this language construct, without renaming all uses of X1 and float.

And then we must embark on a grand naming convention and type usage convention so that we all choose the same names for the same things and use the same types for the same things, and if we do not, then your new language construct is useless when sharing code with other people.

So then we end up spending inordinate amounts of time obsessing over whether we use int64 or int, and what field names we choose, instead of spending that time building our programs.

That is why the proposal needlessly complicates the language - it breaks down in the most simple cases, and any attempt to reconcile that would result in ridiculous complexity. All to do something that we can already do today with a little more typing.

@Merovius
Copy link
Contributor

Merovius commented Jul 4, 2024

TBQH I don't believe there really is significant difficulty about deciding what to do - if anything, there is difficulty in how to spec it and maybe (though I don't think so) how to implement it. But in that last example by @seancfoley the correct answer seems pretty clearly "it should not compile, because p.X does not evaluate to int for all types in the type set of the type parameter". After all, the result of p.X must definitely be assignable to int for that code to be valid, and none of int64 or uint are.

A more interesting question might be about something like fmt.Printf("%T", p.X), or x := p.X. But even there "it should fail to compile, as p.X is, in a meaningful sense, not »the same operation« for all types in the type set" seems like a reasonable answer.

The reason #48522 has not been accepted and implemented is, AIUI, little to do with how it should behave or how to implement it and more to do with "do we really want that and how useful is it?" and - most importantly - with the fact that it definitely interacts with this issue and so doing #48522 before this issue has been addressed is not really sensible.

So FWIW I still do not believe that the speculation about #48522 in this issue are really useful. This issue is, at the end of the day, still not about whether or not (and how) it should be possible to refer to a common field of all types in the type set of a type parameter. It's about whether or not we can get rid of the concept of a core type. If we can, #48522 might follow - if we can't, we might do #48522 in a different way (and then we can re-start talking about that specifically). But until we (and in this case "we" pretty much means the Go team) have investigated the question of core types in general, talking about struct fields specifically is, in my opinion, wasted effort.

@creativecreature
Copy link

@Merovius

TBQH I don't believe there really is significant difficulty about deciding what to do - if anything, there is difficulty in how to spec it and maybe (though I don't think so) how to implement it. But in that last example by @seancfoley the correct answer seems pretty clearly "it should not compile, because p.X does not evaluate to int for all types in the type set of the type parameter". After all, the result of p.X must definitely be assignable to int for that code to be valid, and none of int64 or uint are.

I agree with this. I don't see a reason why it would compile either. The compiler wouldn't be able to generate an identical function for each type in the type constraint (monomorphization/stenciling).

@seancfoley
Copy link

I agree with this. I don't see a reason why it would compile either. The compiler wouldn't be able to generate an identical function for each type in the type constraint (monomorphization/stenciling).

Completely missing the point.

@atdiar
Copy link

atdiar commented Jul 5, 2024

I agree with this. I don't see a reason why it would compile either. The compiler wouldn't be able to generate an identical function for each type in the type constraint (monomorphization/stenciling).

Completely missing the point.

Let's keep it genial :)

The explanation is that the field definition that builds the type set should include the type.
The example above seems to mix several for the definition of .X

But yes, I'm starting to think that @creativecreature is not wrong in that: speaking about this one issue here is not very practical.
Here should be more generally about core types.

@seancfoley
Copy link

seancfoley commented Jul 7, 2024

@atdiar

Let's keep it genial :)

It's perfectly genial to tell someone that they've missed the point you were making. I have no idea why you think otherwise, but it's perfectly congenial to tell someone they've missed the point that was being made, and I don't appreciate your insinuations otherwise.

@atdiar
Copy link

atdiar commented Jul 7, 2024

@atdiar

Let's keep it genial :)

It's perfectly genial to tell someone that they've missed the point you were making. I have no idea why you think otherwise, but it's perfectly congenial to tell someone they've missed the point that was being made, and I don't appreciate your insinuations otherwise.

@seancfoley
If it was in good faith, you could also simply explain. Anyway, let's keep it at that.
I apologize for having misread the intent.
I do think that these terse responses should be used with more care online.

@creativecreature
Copy link

creativecreature commented Jul 7, 2024

@seancfoley I also sensed a bit of unfriendliness, which made me hesitant to engage in a discussion with you. However, I understand that written communication can sometimes come across differently than intended. Let's try to ensure that this thread remains constructive and positive!

What would happen here?

type Point struct {
Y, X int64
}

type Rect struct {
X, Z, Y, W, H uint
}

type Elli struct {
Y, W, H int
Foo
}

type Foo struct {
X int
}

func GetX[P interface { Point | Rect | Elli }] (p P) int {
return p.X
}

I don't think this should compile, and I don't think anyone is advocating for it either. I think a good mental model of stenciling is that GetX should be used as a stencil to stamp out the same function for each of the instantiated types. I understand that this isn't exactly the case since Go uses a hybrid approach and does it for each GC shape (rather than full monomorphization) but it seems obvious to me that the property names and types would still need to be the same.

And then suppose in my code I have

type Box struct {
	X1, X2 float
}

But I cannot combine my type with your types when using this language construct, without renaming all uses of X1 and float.

And then we must embark on a grand naming convention and type usage convention so that we all choose the same names for the same things and use the same types for the same things, and if we do not, then your new language construct is useless when sharing code with other people.

So then we end up spending inordinate amounts of time obsessing over whether we use int64 or int, and what field names we choose, instead of spending that time building our programs.

That is why the proposal needlessly complicates the language - it breaks down in the most simple cases, and any attempt to reconcile that would result in ridiculous complexity. All to do something that we can already do today with a little more typing.

This function:

func GetX[P interface { Point | Rect | Elli }] (p P) int {
	return p.X
}

Defines a type constraint, so you won't be able to pass your Box regardless. Hence, I don't see why you would need to embark on a grand naming convention journey. We're not saying "for any type return X"; we're saying "for one of these three types, which all have X defined as an int, return it," so that I don't have to create an interface and three separate getter functions. All of this is already in the language, except for the possibility to access X. Having to create an abstraction and use an interface for something that isn't behavior but state feels wrong in a language that has generics. You also have to align names and return types with interfaces, so I don't see why that would lead to "ridiculous complexity" regardless.

I'm advocating for this change because I don't think it would significantly impact the way we write Go code, and quite frankly, I believe this code:

type Point struct {
	X, Y int
}

type Rect struct {
	X, Y, W, H int
}

type Elli struct {
	X, Y, W, H int
}

func GetX[P interface { Point | Rect | Elli }] (p P) int {
	return p.X
}

To be much simpler than this:

type Point struct {
	X, Y int
}

func (p Point) GetX() int {
	return p.X
}

type Rect struct {
	X, Y, W, H int
}

func (r Rect) GetX() int {
	return r.X
}

type Elli struct {
	X, Y, W, H int
}

func (e Elli) GetX() int {
	return e.X
}

type GetXer interface {
	GetX() int
}

func GetX(x GetXer) int {
	return x.GetX()
}

Perhaps this examples is too simple to fully illustrate the point, but in a large codebase that handles a lot of business logic, I've seen getter/setter interfaces result in significant amounts of extra boilerplate.

Go generics are too limited for my needs seem to rank really high on the latest developer survey too:

Screenshot 2024-07-07 at 14 18 45

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
NeedsInvestigation Someone must examine and confirm this is a valid issue and not a duplicate of an existing one. umbrella
Projects
None yet
Development

No branches or pull requests