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: support for struct members in interface/constraint syntax #51259

Open
davidmdm opened this issue Feb 18, 2022 · 75 comments
Open
Labels
generics Issue is related to generics Proposal Proposal-Hold
Milestone

Comments

@davidmdm
Copy link

davidmdm commented Feb 18, 2022

Author background

  • Would you consider yourself a novice, intermediate, or experienced Go programmer?
    I consider myself an intermediate to experienced go programmer writing Go for the last 4 years and professionally for the last 2 years.

  • What other languages do you have experience with?
    I have experience in JS/TS, and a tiny bit of rust but nothing to bat an eye at.

Related proposals

  • Is this about generics?

yes

Proposal

  • What is the proposed change?

The proposal is to extend the interface/constraint syntax to support struct members.

  • Who does this proposal help, and why?

This proposal would allow for generic functions to be structurally generic. It would reduce a lot
of boilerplate around getters and setters for simple struct members, and would/might/could increase
performance for generic code by avoiding dynamic dispatch of interface methods for simple property accesses.

  • Please describe as precisely as possible the change to the language.

As a little bit of background, at the time of this proposal there is currently an issue being tracked for go1.19 about being able to use common struct members for generic types that are the union of structs . For example:

type Point2D struct { X, Y float64 }
type Point3D struct { X, Y, Z float64 }

func SomeOperation2D[T Point2D | Point3D](point T) {
   return point.X * point.Y
}

This fails like so:

point.X undefined (type T has no field or method X)
point.Y undefined (type T has no field or method Y)

Now the issue still stands as to whether or not Go should support the intersection of struct members for a union of struct types, but we can let that issue decide that use case.

The interesting thing that came out of the discussion, is that really what we want to express, is to be generic structurally hence:

func DoSomethingWithX[T struct { X float64 }](value T) float64 {
  return value.X
}

p2 := Point2D{}
p3 := Point3D{}

DoSomethingWIthX(p2) // would work becuase Point2D has member X
DoSomethingWithX(p3) // would work because Point3D has member X

However this does not seem to be compatible with what is being released in go1.18.
Consider:

type A struct { X, Y int }
type B struct { X, C int }

func Foo[T A | B](value T) { ... }

We would no longer be able to express that we want exactly struct A or B, as this would express any superset struct of A or any superset struct of B.

Adding the ~ operator to signify we want the approximate shape of the struct seems like a likely candidate:

// Foo accepts any value that has struct member X of type int
func Foo[T ~struct { X int }](value T) { ... }

However this breaks the ~ operator as defined in go1.18 as to mean any type who's underlying type is what follows the tilde.
I do not believe that making a special case for the tilde with a struct to be a good idea for orthogonality in the language.

Therefore, my proposal to is to extends our interface/constraint syntax to include struct members:

type Xer interface {
  X int
}

This works nicely with the idea of type sets and is fully backward compatible semantically with go1.18.

In the same way that type XGetter interface { GetX() int } represents the set of types that implement the method GetX() int, Xer would be the set of types that have a member X.

This way we don't need to touch what the tilde operator means, or how to interpret a type set of structs as constraint.

Slightly more illustrative example of what this might look like in real code:

type Point1D struct { X int }
type Point2D struct { X, Y int }
type Point3D struct { X, Y, Z int }

// type constraint
type TwoDimensional interface { X, Y int }

// Works for any struct with X and Y of type int, including Point2D and Point3D, etc, and excluding Point1D
func TwoDimensionOperation[T TwoDimensional](value T) { 
  return value.X * value.Y  // legal member accesses as described by the TwoDImensional constraint
 }
  • What would change in the language spec?

interace/constraint syntax allowing for struct members.

  • Is this change backward compatible?

yes

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

I believe it to be orthagonal

  • Is the goal of this change a performance improvement?

Not necessarily but it may have performance benefits. Especially since currently, if we want to be able to pass any value with struct member X, we would need to create an interface with Getter and Setter methods; Generic struct member access is likely to prove to be faster than the current dynamic dispatch approach.

If so, what quantifiable improvement should we expect?

Readability and expressivity. Might make some generic code more performant.

  • How would we measure it?

Can't say.

Costs

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

I do not think it would make generics any more complicated than they already are.
Indeed it might solve headaches for people running into this problem and make generics
slightly easier to grasp or express overall.

  • What is the cost of this proposal? (Every language change has a cost).

Might cause confusion when reading struct{ X int } vs interface{ X int }, but I believe this to be minimal.

  • How many tools (such as vet, gopls, gofmt, goimports, etc.) would be affected?
  • What is the compile time cost?
  • What is the run time cost?
  • Can you describe a possible implementation?
  • Do you have a prototype? (This is not required.)

For all the prior questions: can't say. Defer to smarter people.

@gopherbot gopherbot added this to the Proposal milestone Feb 18, 2022
@ianlancetaylor ianlancetaylor added the generics Issue is related to generics label Feb 18, 2022
@ianlancetaylor ianlancetaylor changed the title proposal: Support for struct members in interface/constraint syntax proposal: spec: support for struct members in interface/constraint syntax Feb 18, 2022
@ianlancetaylor
Copy link
Member

CC @griesemer

Thanks. Putting on hold until we have more understanding of how generics play out in 1.18.

@jub0bs
Copy link

jub0bs commented Mar 29, 2022

I'm concerned about this proposal... It seems to shift the emphasis of interfaces from behavior to data. I'd be curious to see actual good use cases for this.

@DeedleFake
Copy link

It seems to shift the emphasis of interfaces from behavior to data.

Indeed. And while I don't inherently dislike the idea of accessing struct members, I don't find the examples to be particularly convincing. Wouldn't a better approach be the way that the image/color package works with interfaces that provide a common form of the data? For example,

type Point interface {
  XYZ() (x, y, z int)
}

type Point1D struct { X int }
func (p Point1D) XYZ() (x, y, z int) { return p.X, 0, 0 }

type Point2D struct { X, Y int }
func (p Point2D) XYZ() (x, y, z int) { return p.X, p.Y, 0 }

type Point3D struct { X, Y int }
func (p Point3D) XYZ() (x, y, z int) { return p.X, p.Y, p.Z }

func TwoDimensionalOperation(point Point) {
  x, y, _ := point.XYZ()
  return x * y
}

A better example might be trying to do the operation in-place, though that runs into issues with pointers and generics if you try to combine it with struct fields:

// Should p1 be a pointer or not? Neither works nicely.
func TwoDimensionalOperation[T TwoDimensional](p1 *T, p2 T)

@davidmdm
Copy link
Author

@jub0bs @DeedleFake,

interfaces are great at describing behavior, and that should be the primary focus of interfaces.

I think what I would like to achieve with the proposal is the constraint aspect. I think the best remark I made in the proposal was the following:

In the same way that type XGetter interface { GetX() int } represents the set of types that implement the method GetX() int, Xer would be the set of types that have a member X.

The solutions you proposed above do satisfy the problem, but at the cost of more code/methods/design-patterns, and at a slight performance cost. Which in my contrived example probably does not matter at all. However this could matter a great deal for scientific computing, graphics, and even for image/color as you mentioned (although with a different intent).

When Go accepted to take on Generics, it accepted that it would need a mechanism to constrain types. In the go1.18 release the mechanism is either by behaviour (standard interfaces), or by type sets (new interface/constraint syntax). This proposal makes the case that constraining types by data is going to be a common ask, and can help code readability and maybe performance. Not to mention that constraining by data is completely analogous to constraining by behavior from a type set perspective.

I do agree that work arounds exist, and have served the community quite well as in the case of image/color.

As a final note, It is also out of scope of this proposal to make a data interface anything other than a type constraint,
and hopefully that shouldn't shift the purpose of interfaces too far away from describing behavior.

@jub0bs
Copy link

jub0bs commented Mar 31, 2022

@davidmdm

This proposal makes the case that constraining types by data is going to be a common ask, and can help code readability and maybe performance. Not to mention that constraining by data is completely analogous to constraining by behavior from a type set perspective.

I find this statement contentious. AFAIU, the need for type sets only arose because of the need to describe types that have a common behavior (e.g. support for comparison operators <, <=, etc.) other than methods. So I would claim that type sets too are focused on behavior and not on data.

@zigo101
Copy link

zigo101 commented Mar 31, 2022

Why methods belong to behaviors, but fields don't?

@jub0bs
Copy link

jub0bs commented Mar 31, 2022

@go101
Methods are what a type, regardless of its nature, can do (behavior).
Fields are what a struct type has (data).

I don't perceive "having a field named Foo of type int" as a behavior.

@zigo101
Copy link

zigo101 commented Mar 31, 2022

In my opinion, it is just a matter of definitions. A field is just a simplification of a getter and a setter methods.

@changkun
Copy link
Member

A field is just a simplification of a getter and a setter methods.

Only if we can override the field access behavior. Getter and setter methods can be implemented in a concurrent safe way but field access does not guarantee thread-safe.

@zigo101
Copy link

zigo101 commented Mar 31, 2022

Getter and setter methods can be implemented in a concurrent safe way but field access does not guarantee thread-safe.

That might be true. But it is generics unrelated.

@changkun
Copy link
Member

changkun commented Mar 31, 2022

Well, I'd say that's is very true and quite related when runtime component might be involved here. A field can be accessed if and only if a pointer type value is not nil, otherwise it results in a runtime panic. In contrast, a method has no such restriction and will not result in a runtime panic. If that is the case, consider the following example:

type F[T any] interface {
    Fn func() T
}

type S[T F[T]] struct {}

func (s *S[T]) Fn() (t T) { return } // Is it allowed?

Or should we say using F in S is not allowed?

@zigo101
Copy link

zigo101 commented Apr 1, 2022

In contrast, a method has no such restriction and will not result in a runtime panic.

This is NOT absolutely true:

A field can be accessed if and only if a pointer type value is not nil, otherwise it results in a runtime panic

And I don't think this is a valid reason to deny this proposal. Generics never promise to prevent code panicking.

[Edit]; BTW, I don't understand the intention of the code you shows.
I just made a bit modification to make it compile.

type F[T any] interface {
    Fn() T
}

type S[T F[T]] struct {}

func (s *S[T]) Fn() T {
	var t T
	return t
}

@changkun
Copy link
Member

changkun commented Apr 1, 2022

The modification is not helpful for discussing the proposal. Thanks.

@changkun
Copy link
Member

changkun commented Apr 1, 2022

Generics never promise to prevent code panicking.

I think it is not our decision regarding promises, and also not sure whether the argument aligns with the mentioned confusing code. Just listing observed examples here, that any was decided to not implement comparable because of runtime panic confusion (one of the reasons).

Allow me to elaborate a bit more. According to this proposal, we could write an interface like this to have an Fn field:

type F[T any] interface {
    Fn func() T
}

Now, combining with the previously mentioned existing language behavior, we have two design choices for the code as follows:

type S[T F[T]] struct {}

func (s *S[T]) Fn() (t T) { return }
  1. Disallowing writing method Fn() in S because S is constrained by F.
  2. Disallowing using F as a type parameter in the struct S.

Either way, I would argue they are both confusing. Deciding each behavior will create the issue of either cannot access the Fn field or cannot call the Fn method.

@zigo101
Copy link

zigo101 commented Apr 1, 2022

Maybe you should elaborate even more. I couldn't understand the following sentences

  • S is constrained by F
  • F as a type parameter in the struct S

@docmerlin
Copy link

docmerlin commented Apr 11, 2022

I like this proposal, it really aids in readability by strongly decreasing boilerplate, and reducing need to make getters and setters.
If the field access could be inline, that would be even more fantastic.

@jub0bs
Copy link

jub0bs commented Apr 12, 2022

I don't like this proposal. Regardless of boilerplate reduction, it takes Go interfaces far away from what they're intended to be: a mechanism for focusing on what a type can do, rather that what a type is composed of. If this proposal gets accepted, this may be the point where Go jumps the shark for me.

@bcmills
Copy link
Contributor

bcmills commented Apr 12, 2022

See previously #19524 (rejected because it was before the Go 2 process started) and #23796 (retracted due to lack of motivating examples‌ — see #23796 (comment)).

@bcmills
Copy link
Contributor

bcmills commented Apr 12, 2022

As I noted in #23796 (comment):

[Requiring that an interface value be implemented by a specific underlying type] is already possible today: if a [value of a] particular type is inspected using reflection, it may be required to be a pointer-to-struct or a channel or obey any number of other invariants. For example, proto.Message is required to be a pointer to a struct type with appropriate field tags. (That's not fundamentally different from the invariants of the standard encoding/json, encoding/gob, or text/template packages: they just happen to use interface{} instead of naming some more specific type.)

To enforce the level of behavioral abstraction you'd like today, you'd have to strike reflection from the language. Given that that's not at all likely to happen (much as I might wish for it), I don't see that it's all that harmful to codify a particularly common type constraint in the interface definition.

That is: we already have those sorts of constraints, and to me it seems strictly positive to promote at the least the common ones from ad-hoc comments into the language proper.

@bcmills
Copy link
Contributor

bcmills commented Apr 12, 2022

@changkun

A field can be accessed if and only if a pointer type value is not nil, otherwise it results in a runtime panic. In contrast, a method has no such restriction and will not result in a runtime panic.

That does not hold in general. Compare, say, the case in #18617, in which invoking a pointer method defined by an embedded type unavoidably panics.

It is true that if a method is not promoted by embedding it is possible to implement that method such that it doesn't panic for a nil pointer, but in practice most methods are not implemented that way anyway — to the extent that the fmt package already has special-case code to detect panics due to nil-dereferences.

@zigo101
Copy link

zigo101 commented Apr 12, 2022

I just realized that a problem (or a difficulty) of this proposal that non-basic interface types might be allowed to be used as value types, then when a value of such non-basic interface type is used as a value argument and passed to a value parameter of a type parameter which constraint is described by this proposal, then modifying a field of the interface argument is illegal.

[Edit]: maybe this is not a big difficulty, but the runtime really needs to change some.

The following code should print 0.0, which means when an interface value is passed to a value parameter of a type parameter,
the dynamic value of the interface value should be duplicated. This is already true in theory now, but not true in implementation.

package main

type Point2D struct { X, Y float64 }
type Point3D struct { X, Y, Z float64 }
type C interface{Point2D | Point3D | *Point2D | *Point3D}

func SomeOperation2D[T C](point T) {
   point.X = 1.0 // If point is an interface, then this is illegal.
}

func main() {
	var v C = Point2D{}
	SomeOperation2D(xv)
	println(v.x) // 0.0 or 1.0?
}

@davidmdm
Copy link
Author

@go101

Your example goes beyond what I am asking for in the proposal. I am mainly interested in extending the interface syntax for use as a type constraint.

The following in your example is not legal under my proposal.

var v C

It might be somebody else's battle to make that valid, should my proposal be accepted.

My main idea is to generically constrain types by their underlying structure.
I think this makes the proposal much simpler, and doesn't involve interface copying.

In fact your example doesn't use any of the syntax I proposed, but simply type sets with legal common field access.

My proposal would look something like this:

To follow your example:

package main

type Point2D struct { X, Y float64 }
type Point3D struct { X, Y, Z float64 }

type TwoDimensional interface{
  X float64
  Y float64
}

func SomeOperation2D[T TwoDimensional](point T) {
   point.X = 1.0
}

func main() {
	point := Point2D{}

        SomeOperation2D(point)	
        println(point.X) // 0.0

        SomeOperation2D(&point)
        println(point.X) // 1.0

        // Showing this block to highlight that Point3D satisfies the TwoDimensional Constraint.
        point3D := Point3D{}
        SomeOperation2D(point3D)	
        println(point3D.X) // 0.0
}

@davidmdm
Copy link
Author

@jub0bs

You have to stop thinking of this as changing what interfaces are for.
This proposal is about extending generic constraints.

Perhaps you would have been much more open to this proposal had Go gone with the contract keyword for constraints as in the original generics proposal.

Unfortunately since interfaces and contracts/constraints have been merged into one concept, which is neither a good or bad thing, just the direction that go went with, updating and extending our ability to constrain generic types will most likely impact interface syntax.

I am not proposing in this proposal that struct members be used for instantiable interfaces. Soley for constraints. In the same way that type sets can only be used in the context of a constraint.

I do recognize that people will propose this down the road, but it's not because this brings us closer to that, that we should never improve how we constrain our generic types.

@davidmdm
Copy link
Author

@ianlancetaylor The proposal actually doesn't suggest using the tilde ~. But was merely using it narratively, illustrating some of my considerations. Indeed the tilde is rejected by the original proposal.

However this breaks the ~ operator as defined in go1.18 as to mean any type who's underlying type is what follows the tilde.
I do not believe that making a special case for the tilde with a struct to be a good idea for orthogonality in the language.
Therefore, my proposal to is to extends our interface/constraint syntax to include struct members

The proposal simply put is to extend the definition of an interface to include struct members / fields.

When introducing interfaces as type constraints, we redefined interfaces as type sets, ie the set of types that have method X.

This proposal keeps the type set framing but where we could express the set of types that have field X of some type.

Also as for use-cases, I do not expect it to be a common every day thing but will help the use case where folks need to express Getter/Setters for their constraints and implement them, instead of expressing the fields they expect. It may also have internal performance benefits if it reduces the amount of dynamic dispatch say in like an image processing lib or other. However that is purely speculation and I can't back that up.

@davidmdm
Copy link
Author

Also to be fair, it's a really big change and I sometimes also wonder if I want this in Go. However I cannot refute why we shouldn't have the ability to express the set of types that contain a certain Named Field with given Type, or to use that to constrain our generic functions. So I hope it stays open, and that it gets considered carefully at some point in the future!

@ianlancetaylor
Copy link
Member

Ah, I see, sorry for misreading the proposal. You are suggesting that we can write interface { F T } to match any struct type with a field F of type T.

@Merovius
Copy link
Contributor

Merovius commented Oct 5, 2023

Just want to put on record here that if we do this, we need to put in place the same restrictions we currently put on interfaces containing methods. That is, we should not allow interfaces containing field-constraints in union elements.

The proposal doesn't mention if it would be allowed for such fields to be type-parameter typed. i.e. is this legal?

type C[T any] interface {
    F T
}

Also, what about embedded fields? The obvious syntax for them would be ambiguous (interface{ F } is already a valid constraint, meaning something different). But it seems a bit strange to me, to have this limitation on the mechanism - to allow specifying that a type has a field, but not that it's an embedded field.

In general, I agree with @jub0bs that I dislike interfaces being about representation instead of behavior. But also, we already did that when we added generics, so I find it difficult to argue that this proposal would cross any additional lines in that regard.

Why methods belong to behaviors, but fields don't?

Because methods can be implemented regardless of representation. While fields are determined exclusively by representation.

@jub0bs
Copy link

jub0bs commented Oct 5, 2023

@Merovius

I dislike interfaces being about representation instead of behavior. But also, we already did that when we added generics

Just to clarify: are you referring to type sets, e.g.

type Plusser interface {
    ~int | ~uint | ~string
}

I'm asking because I question claims that the motivation for type sets ever was to specify representation rather than behaviour. In my example above, the idea is to capture the + behaviour; that the only concrete types in that type set are ints, uints, etc. is only incidental.

@davidmdm
Copy link
Author

davidmdm commented Oct 5, 2023

@Merovius - my thoughts:

we need to put in place the same restrictions we currently put on interfaces containing methods. That is, we should not allow interfaces containing field-constraints in union elements

Sounds good to me!

  1. is type C[T any] interface { F T } legal? I think so. In the same way that interfaces can express methods with type parameters, they should be able to express fields of type parameters.

What about embedded fields?

When we express an interface: type MyInterface interface { X } we are embedding interfaces. I propose this syntax and semantics stay the same and not change.

Suppose the following types:

type Named interface { Name string }

type Person struct { Name string }

type Employee struct {
  Person
  Number int 
}

Here Employee embeds the Person type, and therefore has a promoted field Name of type string satisfying the interface Named.

TLDR: I don't think we need to be able to specify the set of types that embed a field. Just describe the fields that we need to have accessible via our type.

I dislike interfaces being about representation instead of behavior.

I understand this, and pre-generics it made 100% sense. We needed to have a mechanism for polymorphism that abstracted way the internal representation of the object. This is why, I believe, that in one of the generic proposals we had the two distinct concepts: interfaces and constraints. However constraints were folded back into interfaces with the idea that interfaces are no longer a description of a set of behaviours but instead the representation of a set of types.

With that in mind my proposal shouldn't be more offensive than the changes that have already been made to interfaces like interface { int8 | int16 | int 32 | int64 | int }. Since we want to be able to constrain type parameters using interfaces that describe a set of types, we should be able to constrain them by their representation as well as by their behaviours.

@Merovius
Copy link
Contributor

Merovius commented Oct 5, 2023

TLDR: I don't think we need to be able to specify the set of types that embed a field. Just describe the fields that we need to have accessible via our type.

I don't believe that really resolves my concern. An embedded field does more than just promote its fields. It also promotes methods. And it makes the embedded type available as a selector expression. In your example, if e is an Employee, then e.Person is a Person. That is not a property captured by having a field named Name. And then there is this example:

type A struct {
    X int
}
type B struct {
    Y int
}
type C struct {
    X int
    Y int
}
type D struct {
    A
    B
}
type E struct {
    C
}

D andE are quite different, semantically, in a way that isn't really captured by saying "both have fields X and Y".

I think it is a valid answer that you couldn't express that constraint. But it still seems like a limitation to me, that is a strange corner.

Since we want to be able to constrain type parameters using interfaces that describe a set of types, we should be able to constrain them by their representation as well as by their behaviours.

FWIW I don't agree with this inference. I find it hard to argue that we should not include aspects of representation into constraints. But I don't think it is at all self-evident that we should do more of that. And it could prove a rather deep rabbit-hole, to explore all the aspects of representation you might be interested in.

@davidmdm
Copy link
Author

davidmdm commented Oct 5, 2023

I am sorry, but it is unclear to me what the objection is. It feels to me that a lot of this is quite nit-picky, and perhaps a knee-jerk reaction to what would be a big change in interfaces which are very core to Go, and I would not want to rush this decision either.

However, it seems clear to me that the idea of constraining types by data is not a novel idea. It is very analogous to interfaces that describe Getter/Setter functions, except that types wouldn't need to explicitly define those methods just to trivially return their data.

FWIW I don't agree with this inference.

Can you explain why you think we should NOT be able to constrain them (types) by their representation (Fields/Data) as well as by their behaviours?

@Merovius
Copy link
Contributor

Merovius commented Oct 5, 2023

@jub0bs I do agree with you about the intent, but the mechanism is still a constraint on representation. There is still no possible argument that interface{ ~int } is anything but a constraint about how a type is represented, in my opinion.

@davidmdm I chose the term "representation" instead of "data" because it is more accurate to what I'm talking about. An interface having "getters and setters" is relatively accurately described as dealing with "data", but not as "representation".

If you define an interface GetFoo() int (a getter), I can have a func() type with a GetFoo() int method implement that interface. The interface is completely independent from the representation. This interface might express the data your function needs to work, but it doesn't say anything about how that data is represented. This proposal expresses a constraint over representation. I can not implement interface{ Foo int } using a function (or some other) type. Personally, I find that a pretty restrictive constraint and a qualitative change in the roles interfaces have had in the past.

FWIW I've so far avoided really taking a strong position on this proposal, because I don't really have one. I'm leaning towards rejecting it, because I don't like to expand the role of interfaces into constraining representation. But it's not a particularly strong argument, because as of right now, we have some of that already. But I'm also not super happy about that, so I disagree that "we can do some of X, so we should do more of X" is sound reasoning.

I've just tried hammering out the details, to make the implications of this proposal clear. If we want to do this, we should be aware on the restrictions on union-elements, the limitations about embedded fields, or the use of type parameters. I asked about the first, because I wanted to ensure we don't accidentally decide to do something we can't implement. I asked about the latter two, because I remember that there are some limitations about using type parameters - for example, you can't have embedded fields be type-parameters, you can't have generic type-aliases because of some open questions on recursive types and… perhaps others? I'm not sure.

And yes, to me, the limitation that you can't express a constraint on an embedded field is a definite limitation of this proposal. It is just as natural a constraint to express as a constraint to have a non-embedded field. It's possible to do this proposal even with that limitation - but we should acknowledge it exists.

I just felt it would be useful to have these questions answered. I wasn't trying to pick nits.

@davidmdm
Copy link
Author

davidmdm commented Oct 5, 2023

@Merovius

Thanks for that explanation, and I want to apologize if I was accusatory of any ill intention (nit-picking or otherwise). I suppose the conversation was going in directions that I failed to see as important, and it was starting to frustrate me. I apologize.

If I may can I ask you to give your thoughts on the transition to the idea of interfaces as sets of types.

To me fundamentally there seems to be a tension between Go pre and post generics around this topic.

There's no doubt that using interfaces to describe behaviour gives types free reign to implement it regardless of its representation. An interface of methods is a higher and more powerful abstraction than an interface of structure or representation.

However since Go 1.18 and the advent of generics, we've allowed interfaces to represent sets of types, and type unions are a direct consequence of that.

Indeed we can express interfaces to be very restrictive as to the type or structure by making a type set of only 1 type: type Int interface { int }.

It feels to me that being able to create constraints around the structure of types (fields or access to fields) is the missing piece that fits in between enumerating types via type unions, and the set of types that implement a set of behaviours.

My view is that Go is like the ugly duckling stuck in the middle of an odd transition: interfaces represent a set of behaviour / interfaces represent a set of types.

Today it is hard to make interfaces represent the set of types we may want. Indeed we need to modify the types to implement the interfaces and it makes it harder to work with third parties.

This being said, I agree there may many limitations to the approach I have outlined in the proposal. I would be fine with the same limitations that method sets have with type unions today. I would be fine if they could only be used as type constraints and not as interface values in the same way that type unions cannot be used as values in programs today.

Do we commit to interfaces as type sets or no? Without an answer to this question I do not think we should reject this proposal even if it hangs around a long time.

@Merovius
Copy link
Contributor

Merovius commented Oct 5, 2023

I don't think that the salient decision is whether or not interfaces describe "sets of types" or not. That's largely a philosophical question - pre-Go-1.18-interfaces can just as much viewed as sets of types, as post-Go-1.18-interfaces can. The distinction is what kinds of sets of types we can describe. Pre Go 1.18, the sets we could describe where all phrased as "all types that included these methods in their method set". Post Go 1.18, we expanded that description language to include "this concrete type", "all types with this underlying type" and intersections and unions (with some limitations) of these sets. This proposal is to expand that description language to include "sets of struct types with a field F of type T". There are many other possible extensions to the description language of type sets we can envision.

But it will never be possible to describe all sets of types (there are complicated reasons why that would be infeasible, if not impossible, to implement). For example, "the set of all int-arrays with a prime length" is a set of types that we will never be able to describe. Ultimately, we will have to make concrete decisions about each and every extension to that constraint language. And such concrete decisions will have to be based on concrete criteria, like 1. how useful is this extension and 2. what are its costs.

I don't really agree with you, that this proposal is "the (sic) missing piece" in the constraint language. It is certainly something we can't do right now. It's certainly something reasonable to want to do. But I'm not convinced that it is a particularly important missing piece, personally. For example, it would seem far more useful and powerful to me, to have variadic type parameters or some other facility to express concepts like "a function taking an arbitrary number of inputs and an arbitrary number of outputs, the last of which is an error". Which would be useful to write generic error-handlers. Or would simplify the iterators discussion immensely. The inability to manipulate function types this way seems a far more significant hindrance to the kind of generic code we can write today - at least from my limited perspective.

But making that decision requires 1. use cases and 2. the kinds of detailed questions I tried to get into above. Ultimately, those are the important questions that most proposals will come down to: What kinds of code does it enable us to write and is that justifying its cost? Evaluating the cost requires us to know what is proposed exactly.

Without an answer to this question I do not think we should reject this proposal even if it hangs around a long time.

Note that I did not suggest to reject this proposal. [well, that is untrue, I did say I lean towards rejecting it. Though I also said it's not a strong opinion] From what I can tell, the discussion is on-going. To bring us out of the realm of philosophizing and back to the specifics:

  1. Ian has said he is unsure about how often this issue comes up. There are some cases mentioned in proposal: spec: permit referring to a field shared by all elements of a type set #48522. I think it would be interesting or helpful to try to quantify these (though I don't know how).
  2. Ian has also said that unless we can find an answer to how this proposal would work without the distinction between constraints and interfaces, it won't be accepted (for now).

Personally, I strongly agree that if we do this proposal, it should be allowed to use these interfaces as normal types. I've run into the FileInfo use case mentioned here myself just a few days ago. And also happened upon it when trying to avoid boilerplate with the subcommands package. Personally, I have more use-cases to use the proposed interfaces as normal types, than with generic code.

So, I'd recommend focusing on these concrete questions.

@mitar
Copy link
Contributor

mitar commented Oct 20, 2023

I have another example I would like to have supported. I originally posted it in #48522 but was directed here. This issue seems to have complications of how multiple constraints combine together, but in my use case I really care only about accessing to the embedded struct:

type Base struct {
	Name string
}

type Extended struct {
	Base
	Age int
}

func SayHi[T Base](x T) (T, string) {
	return x, fmt.Sprintf("Hi, %s!", x.Name)
}

func main() {
	x, hi := SayHi(Extended{Name: "foo", Age: 30})
	fmt.Println(hi)
	fmt.Println(x.Age)
}

The example is a bit contrived, but the idea is that SayHi should be able to access everything on embedded Base (both fields and methods), but the returned x is in fact of the type passed in. So the extra fields and methods are available and can be passed through, while my code just cares about the Base.

@Merovius
Copy link
Contributor

@mitar Personally, I'm not a fan. IMO the easiest way to write that function would be to have SayHi just take a *Base. But even ignoring that - currently Base refers to exactly the type Base¹, not anything embedding it. You can't pass an Extended to a func (b Base), for example. I do not like the idea of having it mean "or something embedding Base" in some contexts.

But note that I mentioned above, that we should - in my opinion - also consider how to express the constraint that a field is embedded, if we do accept this proposal. That would subsume what you want, because you could then write func SayHi[T Base|HoweverWeExpressEmbeddingBase]. And it would make the embedding relationship explicit, avoiding my criticism.

[1] And I'd also note that the choice of type-names suggests that you are trying to do OOP-hierarchies and inheritance. If we think that is a good idea, we should introduce inheritance, not inheritance-but-worse. In my opinion.

@mitar
Copy link
Contributor

mitar commented Oct 20, 2023

IMO the easiest way to write that function would be to have SayHi just take a *Base.

But then it cannot return also the original Extended value as well.

And I'd also note that the choice of type-names suggests that you are trying to do OOP-hierarchies and inheritance.

No, I am trying to support base functionality where one can extend it for their needs but my code just cares about base functionality. So I do not need that extended struct overrides/shadows base fields/methods. Just that that extended data is passed through.

Currently I am using reflect to achieve this:

// FindInStruct returns a pointer to the field of type T found in struct value.
// value can be of type T by itself and in that case it is simply returned.
func FindInStruct[T any](value interface{}) (*T, errors.E) {
	// TODO: Replace with reflect.TypeFor.
	typeToGet := reflect.TypeOf((*T)(nil)).Elem()
	val := reflect.ValueOf(value).Elem()
	typ := val.Type()
	if typ == typeToGet {
		return val.Addr().Interface().(*T), nil
	}
	fields := reflect.VisibleFields(typ)
	for _, field := range fields {
		if field.Type == typeToGet {
			return val.FieldByIndex(field.Index).Addr().Interface().(*T), nil
		}
	}

	errE := errors.WithDetails(
		ErrNotFoundInStruct,
		"getType", fmt.Sprintf("%T", *reflect.ValueOf(new(T)).Interface().(*T)),
		"valueType", fmt.Sprintf("%T", value),
	)
	return nil, errE
}

This allows me to search for Base in Extended, or somebody can just pass Base in. And then I can access fields and methods on it. The issue is that there is no type checking.

@Merovius
Copy link
Contributor

But then it cannot return also the original Extended value as well.

It doesn't need to. It takes a pointer.

@davidmdm
Copy link
Author

davidmdm commented Oct 20, 2023

@mitar

I think that the under the spirit of this proposal, the example would work slightly differently. What we would want to express here is that SayHi expects a type that has access to a Name field of type string.

IE:

type Named interface {
  Name string
}

// SayHi is constrained by the Named interface which represents
// the set of types that have access to a field Name of type string
func SayHi[T Named](x T) (T, string) {
	return x, fmt.Sprintf("Hi, %s!", x.Name)
}

type Base struct {
	Name string
}

type Extended struct {
	Base
	Age int
}

func main() {
        // Here SayHi is instantiated as SayHi[Extended], 
        // since Extended satisfies the interface "Named" via its promoted field from Base.
	x, hi := SayHi(Extended{Name: "foo", Age: 30})
	fmt.Println(hi)
	fmt.Println(x.Age)
}

@Merovius

Under this proposal at least, I don't think that specifying "embeded-ness" of a field to be vital. If this were to pass it could be a separate proposal.

Although I agree with you about this example having its roots in OOP and that I do not recommend thinking in those terms when it comes to Go. If we wanted to follow out the thinking of the example but with a Go flavour, we would think about the set of types that have a Base instead of the set of types that are (or extend) a Base.

In that case, following this proposal, you could write:

type Based interface {
  Base Base
}

Of which any type that has access to a Base field of type Base would satisfy the interface. This includes both the set of types that have a field explicitly called Base of type Base: struct { Base Base } and types that embed base: struct { Base }. In both cases the Base field is accessible as x.Base.

In my opinion expressing the set of types that embed Base should not block this proposal in and of itself, and if that indeed is a desire that comes up frequently enough, it should be revisited on its own.

edit - typos

@Merovius
Copy link
Contributor

@davidmdm To be clear, my main concern was to have an answer of how to handle embedded fields. "Writing X X allows structs with embedded fields of type X to satisfy the constraint" is such an answer (you didn't say you intended that to be possible above, as far as I remember).

I'm not sure what I think about that answer, but it's something to think about.

@Merovius
Copy link
Contributor

Merovius commented Oct 20, 2023

One thing I'd point out is that not every embedded field has the same name as the type. For example

type A struct { X int }
type B = A
type C struct { B }

C embeds a field of type A, but it is named B. I think that in this case, the constraint would have to be spelled interface{ B A } - which notably wouldn't be satisfied by struct{ A }. That's probably still the right semantic under this proposal, but it's a subtle semantic difference to what @mitar requested.

@mitar
Copy link
Contributor

mitar commented Oct 20, 2023

I found another approach:

type Base struct {
	Name string
}

func (b *Base) GetBase() *Base {
	return b
}

type hasBase interface {
	GetBase() *Base
}

type Extended struct {
	Base
	Age int
}

func SayHi[T hasBase](x T) (T, string) {
	return x, fmt.Sprintf("Hi, %s!", x.GetBase().Name)
}

func main() {
	x, hi := SayHi(&Extended{Base: Base{Name: "foo"}, Age: 30})
	fmt.Println(hi)
	fmt.Println(x.Age)
}

So it seems I just want a way to not have to define GetBase and hasBase but be able to directly access Base.

@zigo101
Copy link

zigo101 commented Oct 21, 2023

I agree with @davidmdm's opinion. Personally, I even think "embedding field" should never play a role in constraints. Field sets in constraints should always denote behaviors, no others.

Nitpick to @davidmdm's code: Extended{Name: "foo", Age: 30} should be written as Extended{Base: Base{Name: "foo"}, Age: 30}.

@davidmdm
Copy link
Author

@go101 thanks. I did some copy-pasta!

@doggedOwl
Copy link

doggedOwl commented Nov 3, 2023

I am very confused by this "must be behaviour" discussion because that ship sailed when interfaces were extended to denote also constrains.
A constrain of type int | float does not specify a behaviour but a set of types. with some mental gymnastics you can try to interpret this as types that satisfy behaviour + but that is simply false because that behviour is clearly more spread than just to int | float. if we were defining that behaviour than strings would be acceptable too.

Type Parametrization is clearly about types and not about behaviour.

By definition: "an operation is permitted if it is permitted for all types in the type set defined by the constraint". Clearly if all types of the set permitt the operation "access to field X" this is within that definition.

Now I also undertand that finding a generic way to express all possible definitions of a type is hard and maybe even not feasable but let's not waste time discussing about decisions that are already in place.

@gophun
Copy link

gophun commented Nov 3, 2023

with some mental gymnastics you can try to interpret this as types that satisfy behaviour + but that is simply false because that behviour is clearly more spread than just to int | float. if we were defining that behaviour than strings would be acceptable too.

Operators were the sole reason this syntax was introduced at all. Anybody using A | B for some other purpose than capturing an operator is basically abusing it.

@Merovius
Copy link
Contributor

Merovius commented Nov 3, 2023

@gophun I don't believe that is true. For one, it is a necessary feature to allow specifying that a method must be on a pointer type, which has little to do with operators. More importantly, an important thought behind union elements was also #57644. And if we really where only concerned with operators, we would have only allowed ~T elements in unions. There's no reason to even have something like float|int if it's just about operators.

Really, unions just say what they say: A type argument must be one of these listed types. And it's a reasonable constraint. No abuse here.

@Merovius
Copy link
Contributor

Merovius commented Nov 3, 2023

@doggedOwl

By definition: "an operation is permitted if it is permitted for all types in the type set defined by the constraint". Clearly if all types of the set permitt the operation "access to field X" this is within that definition.

That is not what this issue is about. This issue is about introducing a new kind of constraint. Allowing field access on already expressible constraints is #48522 (TBQH it's a bit strange to me, that these two issues constantly get mixed up. ISTM their titles are sufficiently clear in their respective intent).

@doggedOwl
Copy link

doggedOwl commented Nov 4, 2023

@Merovius yes I know that is another issue but in my confusion that the merits of this proposal are being discussed on some false premises I also failed to express that I don't support this proposal because it's introducing a constrain that is already inherit in the type definition itself if rules were relaxed (in accordance with the definition) so that #48522 was permitted. (at least when you know the list of types in question, the more generic case that this would enable is in my opinion more complexity for little added benefit over the #48522)

@gophun access to operators is one of aspects but the also one of the main benefits, at least for me, is to use compile time type safety on many places where interface{} was used before. In that context, the constrain of type unions like type1 | type2 etc are very important and again have nothing to do with behaviour. This use cases are at the moment being hindered or at least not ergonomic enough because of some restrictions that were put in place to get the initial version out faster but that can be relaxed because they are still within the initial vision of what type parameters would enable in Go.

@gophun
Copy link

gophun commented Nov 4, 2023

@gophun access to operators is one of aspects but the also one of the main benefits, at least for me, is to use compile time type safety on many places where interface{} was used before. In that context, the constrain of type unions like type1 | type2 etc are very important and again have nothing to do with behaviour.

It's ok to want type unions, but don't abuse generics for it. Type unions belong first and foremost on the non-generic side of the type realm, which would be #57644.

@DeanPDX
Copy link

DeanPDX commented Sep 11, 2024

I run into situations where I would like this functionality from time to time. Today I was working on a system with quite a few reports that are mostly rendered as charts on a dashboard. So I have 9 charts, and each has its' own type. But each of them has similar functionality in that they have a concept of TargetMonth. I don't know if I will have data for each month in the database (I'm bulk-loading it from a system that is outside my control) but I always want to display a single fiscal year in each chart. I also have a "target" that I want to include in my padded rows as it doesn't change over time.

So what I want to do is create a function like this:

type ReportRow interface {
	FiscalMonth 	time.Time
	Target 		int
}

// Ensure we have a row for each month in the fiscal year for any
// report row that has `FiscalMonth` property.
func EnsureFiscalYear[T ReportRow](rows []T) []T {
	fiscalStart := currentFiscalStart()
	dest := make([]T, 12)
	for i := range dest {
		currentMonth := fiscalStart.AddDate(0, i, 0)
		found := false
		// If we have an existing row in our collection, use that
		for _, v := range rows {
			if v.FiscalMonth == currentMonth {
				dest[i] = v
				found = true
				break
			}
		}
		// If we didn't find a row for this fiscal month, pad with zero value
		if !found {
			var row T
			row.FiscalMonth = currentMonth
			// If we have any rows, we can set the target to a meaningful value on our zero value row
			if len(rows) > 0 {
				row.Target = rows[0].Target
			}
			dest[i] = row
		}
	}
	return dest
}

My Current Solution

I landed on doing something like this:

type ReportRow[T any] interface {
	GetFiscalMonth() time.Time
	WithBaseData(original T, fiscalMonth time.Time) T
}

// Possibly the ugliest function signature of all time. Forgive me.
func EnsureFiscalYear[T ReportRow[T]](rows []T) []T {
	// If we have 0 rows nothing we can really do.
	if (len(rows) == 0) {
		return rows
	}
	fiscalStart := currentFiscalStart()
	dest := make([]T, 12)
	for i := range dest {
		currentMonth := fiscalStart.AddDate(0, i, 0)
		found := false
		// If we have an existing row in our collection, use that
		for _, v := range rows {
			if v.GetFiscalMonth() == currentMonth {
				dest[i] = v
				found = true
				break
			}
		}
		// If we didn't find a row for this fiscal month, pad with zero value
		if !found {
			var row T
			dest[i] = row.WithBaseData(rows[0], currentMonth)
		}
	}
	return dest
}

type ReportARow struct {
	FiscalMonth time.Time
	Target      int
	// ...
}

// And then for each report type I have to
// copy/paste to implement the interface:
func (r ReportARow) GetFiscalMonth() time.Time {
	return r.FiscalMonth
}
func (r ReportARow) WithBaseData(original ReportARow, fiscalMonth time.Time) ReportARow {
	r.FiscalMonth = fiscalMonth
	r.Target = original.Target
	return r
}
// repeat for every report type...

I think my case is odd because not only do I want to do the same thing to similar-looking data (in that case, use an interface), I also want to mutate the data based on a shared set of properties. I'm actually pretty happy with the implementation and wanted to share with anybody who is looking for solutions here. It's a little copy/pasty to implement the interface, but, I was able to keep my EnsureFiscalYear logic to a single function which was my main goal.

Anyway, I do think there is value in being able to constrain items to types that have a shared set of properties and wanted to add a real-world scenario here.

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 Proposal Proposal-Hold
Projects
None yet
Development

No branches or pull requests